detached 1ef46d7

Merge feature: 005-webkit-memory-optimization (opt-in memory controls, CGO hardening, performance-first defaults)

commit
1ef46d720b3bdf457320922789ee05180bd42d59
author
bamen · · 2025-09-09 16:52 UTC
committer
bamen · · 2025-09-09 19:27 UTC
merge of
088dfc8 + aedb969 · diff against the first parent
signature
unsigned
55 files · +4221 -1885
M internal/cli/browse.go +22 -22
@@ -17,11 +17,11 @@ import (
1717
1818 // NewBrowseCmd creates the browse command
1919 func NewBrowseCmd() *cobra.Command {
20- var renderingMode string
20+ var renderingMode string
2121
22- cmd := &cobra.Command{
23- Use: "browse <url|query>",
24- Short: "Browse a URL or search query",
22+ cmd := &cobra.Command{
23+ Use: "browse <url|query>",
24+ Short: "Browse a URL or search query",
2525 Long: `Browse a URL directly or use search shortcuts like:
2626 g:golang -> Google search for "golang"
2727 gh:cobra -> GitHub search for "cobra"
@@ -32,21 +32,21 @@ Direct URLs are also supported:
3232 https://example.com
3333 example.com (automatically adds https://)`,
3434 Args: cobra.ExactArgs(1),
35- RunE: func(_ *cobra.Command, args []string) error {
36- // Propagate rendering mode to GUI process via env var
37- if renderingMode != "" {
38- rm := strings.ToLower(renderingMode)
39- switch rm {
40- case "auto", "gpu", "cpu":
41- _ = os.Setenv("DUMBER_RENDERING_MODE", rm)
42- default:
43- return fmt.Errorf("invalid --rendering-mode: %s (expected auto|gpu|cpu)", renderingMode)
44- }
45- }
46- cli, err := NewCLI()
47- if err != nil {
48- return fmt.Errorf("failed to initialize CLI: %w", err)
49- }
35+ RunE: func(_ *cobra.Command, args []string) error {
36+ // Propagate rendering mode to GUI process via env var
37+ if renderingMode != "" {
38+ rm := strings.ToLower(renderingMode)
39+ switch rm {
40+ case "auto", "gpu", "cpu":
41+ _ = os.Setenv("DUMBER_RENDERING_MODE", rm)
42+ default:
43+ return fmt.Errorf("invalid --rendering-mode: %s (expected auto|gpu|cpu)", renderingMode)
44+ }
45+ }
46+ cli, err := NewCLI()
47+ if err != nil {
48+ return fmt.Errorf("failed to initialize CLI: %w", err)
49+ }
5050 defer func() {
5151 if closeErr := cli.Close(); closeErr != nil {
5252 fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
@@ -57,10 +57,10 @@ Direct URLs are also supported:
5757 },
5858 }
5959
60- // Flags
61- cmd.Flags().StringVar(&renderingMode, "rendering-mode", "", "Rendering mode: auto|gpu|cpu")
60+ // Flags
61+ cmd.Flags().StringVar(&renderingMode, "rendering-mode", "", "Rendering mode: auto|gpu|cpu")
6262
63- return cmd
63+ return cmd
6464 }
6565
6666 // browse handles the core browsing logic
M internal/cli/history.go +10 -10
@@ -102,7 +102,7 @@ func listHistory(cmd *cobra.Command, _ []string) error {
102102 }()
103103
104104 ctx := context.Background()
105-
105+
106106 limit, _ := cmd.Flags().GetInt("limit")
107107 verbose, _ := cmd.Flags().GetBool("verbose")
108108
@@ -123,7 +123,7 @@ func listHistory(cmd *cobra.Command, _ []string) error {
123123 if verbose {
124124 fmt.Fprintln(w, "ID\tVISITS\tLAST VISITED\tURL\tTITLE")
125125 fmt.Fprintln(w, "--\t------\t------------\t---\t-----")
126-
126+
127127 for _, entry := range history {
128128 visits := "1"
129129 if entry.VisitCount.Valid {
@@ -143,7 +143,7 @@ func listHistory(cmd *cobra.Command, _ []string) error {
143143 url := truncateString(entry.Url, 50)
144144 title = truncateString(title, 40)
145145
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",
147147 entry.ID, visits, lastVisited, url, title)
148148 }
149149 } else {
@@ -250,7 +250,7 @@ func clearHistory(cmd *cobra.Command, _ []string) error {
250250 var response string
251251 _, _ = fmt.Scanln(&response)
252252 response = strings.ToLower(strings.TrimSpace(response))
253-
253+
254254 if response != "y" && response != "yes" {
255255 fmt.Println("Operation cancelled.")
256256 return nil
@@ -340,20 +340,20 @@ func showStats(_ *cobra.Command, _ []string) error {
340340 fmt.Println("Browsing History Statistics")
341341 fmt.Println("==========================")
342342 fmt.Printf("Total unique URLs: %d\n", totalEntries)
343-
343+
344344 if totalVisits.Valid {
345345 fmt.Printf("Total visits: %d\n", totalVisits.Int64)
346346 }
347347
348348 if oldestDate.Valid && newestDate.Valid {
349- fmt.Printf("Date range: %s to %s\n",
350- oldestDate.Time.Format("2006-01-02"),
349+ fmt.Printf("Date range: %s to %s\n",
350+ oldestDate.Time.Format("2006-01-02"),
351351 newestDate.Time.Format("2006-01-02"))
352352 }
353353
354354 fmt.Println("\nTop 5 domains:")
355355 fmt.Println("--------------")
356-
356+
357357 w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
358358 defer w.Flush()
359359
@@ -363,7 +363,7 @@ func showStats(_ *cobra.Command, _ []string) error {
363363 for rows.Next() {
364364 var domain string
365365 var urlCount, visitCount int64
366-
366+
367367 if err := rows.Scan(&domain, &urlCount, &visitCount); err != nil {
368368 continue
369369 }
@@ -377,4 +377,4 @@ func showStats(_ *cobra.Command, _ []string) error {
377377 }
378378
379379 return nil
380-}⏎̸
380+}
M internal/config/config.go +84 -84
@@ -16,23 +16,23 @@ import (
1616
1717 // Config represents the complete configuration for dumber.
1818 type Config struct {
19- Database DatabaseConfig `mapstructure:"database" yaml:"database"`
20- History HistoryConfig `mapstructure:"history" yaml:"history"`
21- SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`
22- Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"`
23- Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`
24- Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`
25- // RenderingMode controls GPU/CPU rendering selection for WebKit
26- RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
19+ Database DatabaseConfig `mapstructure:"database" yaml:"database"`
20+ History HistoryConfig `mapstructure:"history" yaml:"history"`
21+ SearchShortcuts map[string]SearchShortcut `mapstructure:"search_shortcuts" yaml:"search_shortcuts"`
22+ Dmenu DmenuConfig `mapstructure:"dmenu" yaml:"dmenu"`
23+ Logging LoggingConfig `mapstructure:"logging" yaml:"logging"`
24+ Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"`
25+ // RenderingMode controls GPU/CPU rendering selection for WebKit
26+ RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
2727 }
2828
2929 // RenderingMode selects GPU vs CPU rendering.
3030 type RenderingMode string
3131
3232 const (
33- RenderingModeAuto RenderingMode = "auto"
34- RenderingModeGPU RenderingMode = "gpu"
35- RenderingModeCPU RenderingMode = "cpu"
33+ RenderingModeAuto RenderingMode = "auto"
34+ RenderingModeGPU RenderingMode = "gpu"
35+ RenderingModeCPU RenderingMode = "cpu"
3636 )
3737
3838 // DatabaseConfig holds database-related configuration.
@@ -52,8 +52,8 @@ type HistoryConfig struct {
5252
5353 // SearchShortcut represents a search shortcut configuration.
5454 type SearchShortcut struct {
55- URL string `mapstructure:"url" yaml:"url" json:"url"`
56- Description string `mapstructure:"description" yaml:"description" json:"description"`
55+ URL string `mapstructure:"url" yaml:"url" json:"url"`
56+ Description string `mapstructure:"description" yaml:"description" json:"description"`
5757 }
5858
5959 // DmenuConfig holds dmenu/rofi integration configuration.
@@ -81,12 +81,12 @@ type LoggingConfig struct {
8181
8282 // AppearanceConfig holds UI/rendering preferences.
8383 type AppearanceConfig struct {
84- // Default fonts for pages that do not specify fonts.
85- SansFont string `mapstructure:"sans_font" yaml:"sans_font"`
86- SerifFont string `mapstructure:"serif_font" yaml:"serif_font"`
87- MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"`
88- // Default font size in CSS pixels (approx).
89- DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size"`
84+ // Default fonts for pages that do not specify fonts.
85+ SansFont string `mapstructure:"sans_font" yaml:"sans_font"`
86+ SerifFont string `mapstructure:"serif_font" yaml:"serif_font"`
87+ MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font"`
88+ // Default font size in CSS pixels (approx).
89+ DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size"`
9090 }
9191
9292 // Manager handles configuration loading, watching, and reloading.
@@ -119,7 +119,7 @@ func NewManager() (*Manager, error) {
119119 v.AutomaticEnv()
120120
121121 // Bind specific environment variables
122- bindings := map[string]string{
122+ bindings := map[string]string{
123123 "database.path": "DATABASE_PATH",
124124 "database.max_connections": "DATABASE_MAX_CONNECTIONS",
125125 "database.max_idle_time": "DATABASE_MAX_IDLE_TIME",
@@ -147,16 +147,16 @@ func NewManager() (*Manager, error) {
147147 "logging.compress": "LOGGING_COMPRESS",
148148 }
149149
150- for key, env := range bindings {
151- if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil {
152- return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
153- }
154- }
150+ for key, env := range bindings {
151+ if err := v.BindEnv(key, "DUMB_BROWSER_"+env); err != nil {
152+ return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err)
153+ }
154+ }
155155
156- // Explicit binding for rendering mode via dedicated env var
157- if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
158- return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
159- }
156+ // Explicit binding for rendering mode via dedicated env var
157+ if err := v.BindEnv("rendering_mode", "DUMBER_RENDERING_MODE"); err != nil {
158+ return nil, fmt.Errorf("failed to bind DUMBER_RENDERING_MODE: %w", err)
159+ }
160160
161161 return &Manager{
162162 viper: v,
@@ -189,11 +189,11 @@ func (m *Manager) Load() error {
189189 }
190190 }
191191
192- // Unmarshal into config struct
193- config := &Config{}
194- if err := m.viper.Unmarshal(config); err != nil {
195- return fmt.Errorf("failed to unmarshal config: %w", err)
196- }
192+ // Unmarshal into config struct
193+ config := &Config{}
194+ if err := m.viper.Unmarshal(config); err != nil {
195+ return fmt.Errorf("failed to unmarshal config: %w", err)
196+ }
197197
198198 // Set database path if not specified
199199 if config.Database.Path == "" {
@@ -204,20 +204,20 @@ func (m *Manager) Load() error {
204204 config.Database.Path = dbPath
205205 }
206206
207- // Normalize/validate rendering mode
208- switch strings.ToLower(string(config.RenderingMode)) {
209- case "", string(RenderingModeAuto):
210- config.RenderingMode = RenderingModeAuto
211- case string(RenderingModeGPU):
212- config.RenderingMode = RenderingModeGPU
213- case string(RenderingModeCPU):
214- config.RenderingMode = RenderingModeCPU
215- default:
216- config.RenderingMode = RenderingModeAuto
217- }
218-
219- m.config = config
220- return nil
207+ // Normalize/validate rendering mode
208+ switch strings.ToLower(string(config.RenderingMode)) {
209+ case "", string(RenderingModeAuto):
210+ config.RenderingMode = RenderingModeAuto
211+ case string(RenderingModeGPU):
212+ config.RenderingMode = RenderingModeGPU
213+ case string(RenderingModeCPU):
214+ config.RenderingMode = RenderingModeCPU
215+ default:
216+ config.RenderingMode = RenderingModeAuto
217+ }
218+
219+ m.config = config
220+ return nil
221221 }
222222
223223 // Get returns the current configuration (thread-safe).
@@ -277,10 +277,10 @@ func (m *Manager) reload() error {
277277 return err
278278 }
279279
280- config := &Config{}
281- if err := m.viper.Unmarshal(config); err != nil {
282- return err
283- }
280+ config := &Config{}
281+ if err := m.viper.Unmarshal(config); err != nil {
282+ return err
283+ }
284284
285285 // Set database path if not specified
286286 if config.Database.Path == "" {
@@ -291,25 +291,25 @@ func (m *Manager) reload() error {
291291 config.Database.Path = dbPath
292292 }
293293
294- // Normalize/validate rendering mode
295- switch strings.ToLower(string(config.RenderingMode)) {
296- case "", string(RenderingModeAuto):
297- config.RenderingMode = RenderingModeAuto
298- case string(RenderingModeGPU):
299- config.RenderingMode = RenderingModeGPU
300- case string(RenderingModeCPU):
301- config.RenderingMode = RenderingModeCPU
302- default:
303- config.RenderingMode = RenderingModeAuto
304- }
305-
306- m.config = config
307- return nil
294+ // Normalize/validate rendering mode
295+ switch strings.ToLower(string(config.RenderingMode)) {
296+ case "", string(RenderingModeAuto):
297+ config.RenderingMode = RenderingModeAuto
298+ case string(RenderingModeGPU):
299+ config.RenderingMode = RenderingModeGPU
300+ case string(RenderingModeCPU):
301+ config.RenderingMode = RenderingModeCPU
302+ default:
303+ config.RenderingMode = RenderingModeAuto
304+ }
305+
306+ m.config = config
307+ return nil
308308 }
309309
310310 // setDefaults sets default configuration values in Viper.
311311 func (m *Manager) setDefaults() {
312- defaults := DefaultConfig()
312+ defaults := DefaultConfig()
313313
314314 // Database defaults
315315 m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections)
@@ -336,23 +336,23 @@ func (m *Manager) setDefaults() {
336336 m.viper.SetDefault("dmenu.date_format", defaults.Dmenu.DateFormat)
337337 m.viper.SetDefault("dmenu.sort_by_visit_count", defaults.Dmenu.SortByVisitCount)
338338
339- // Logging defaults
340- m.viper.SetDefault("logging.level", defaults.Logging.Level)
341- m.viper.SetDefault("logging.format", defaults.Logging.Format)
342- m.viper.SetDefault("logging.filename", defaults.Logging.Filename)
343- m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize)
344- m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups)
345- m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
346- m.viper.SetDefault("logging.compress", defaults.Logging.Compress)
347-
348- // Appearance defaults
349- m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
350- m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
351- m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
352- m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
353-
354- // Rendering defaults
355- m.viper.SetDefault("rendering_mode", string(RenderingModeAuto))
339+ // Logging defaults
340+ m.viper.SetDefault("logging.level", defaults.Logging.Level)
341+ m.viper.SetDefault("logging.format", defaults.Logging.Format)
342+ m.viper.SetDefault("logging.filename", defaults.Logging.Filename)
343+ m.viper.SetDefault("logging.max_size", defaults.Logging.MaxSize)
344+ m.viper.SetDefault("logging.max_backups", defaults.Logging.MaxBackups)
345+ m.viper.SetDefault("logging.max_age", defaults.Logging.MaxAge)
346+ m.viper.SetDefault("logging.compress", defaults.Logging.Compress)
347+
348+ // Appearance defaults
349+ m.viper.SetDefault("appearance.sans_font", defaults.Appearance.SansFont)
350+ m.viper.SetDefault("appearance.serif_font", defaults.Appearance.SerifFont)
351+ m.viper.SetDefault("appearance.monospace_font", defaults.Appearance.MonospaceFont)
352+ m.viper.SetDefault("appearance.default_font_size", defaults.Appearance.DefaultFontSize)
353+
354+ // Rendering defaults
355+ m.viper.SetDefault("rendering_mode", string(RenderingModeAuto))
356356 }
357357
358358 // createDefaultConfig creates a default configuration file.
M internal/config/defaults.go +18 -18
@@ -7,7 +7,7 @@ import (
77
88 // DefaultConfig returns the default configuration values for dumber.
99 func DefaultConfig() *Config {
10- return &Config{
10+ return &Config{
1111 Database: DatabaseConfig{
1212 MaxConnections: 1,
1313 MaxIdleTime: time.Minute * 5,
@@ -46,23 +46,23 @@ func DefaultConfig() *Config {
4646 DateFormat: "2006-01-02 15:04",
4747 SortByVisitCount: true,
4848 },
49- Logging: LoggingConfig{
50- Level: "info",
51- Format: "text", // text or json
52- Filename: "", // empty means stdout
53- MaxSize: 100, // MB
54- MaxBackups: 3,
55- MaxAge: 7, // days
56- Compress: true,
57- },
58- Appearance: AppearanceConfig{
59- SansFont: "Fira Sans",
60- SerifFont: "Fira Sans",
61- MonospaceFont: "Fira Code",
62- DefaultFontSize: 16,
63- },
64- RenderingMode: RenderingModeAuto,
65- }
49+ Logging: LoggingConfig{
50+ Level: "info",
51+ Format: "text", // text or json
52+ Filename: "", // empty means stdout
53+ MaxSize: 100, // MB
54+ MaxBackups: 3,
55+ MaxAge: 7, // days
56+ Compress: true,
57+ },
58+ Appearance: AppearanceConfig{
59+ SansFont: "Fira Sans",
60+ SerifFont: "Fira Sans",
61+ MonospaceFont: "Fira Code",
62+ DefaultFontSize: 16,
63+ },
64+ RenderingMode: RenderingModeAuto,
65+ }
6666 }
6767
6868 // 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 {
117117 }
118118
119119 return nil
120-}⏎̸
120+}
M internal/db/interfaces.go +1 -1
@@ -34,4 +34,4 @@ type DatabaseQuerier interface {
3434 }
3535
3636 // 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 {
437437 if domain == query {
438438 return true
439439 }
440-
441- // Check if query is part of domain (e.g., "github" in "github.com" or "www.github.com")
440+
441+ // Check if query is part of domain (e.g., "github" in "github.com" or "www.github.com")
442442 if strings.HasSuffix(domain, "."+query) || strings.HasPrefix(domain, query+".") {
443443 return true
444444 }
445-
445+
446446 // Check if query matches a significant part of domain (not just a substring)
447447 // Split domain by dots and check if any part matches query
448448 domainParts := strings.Split(domain, ".")
@@ -451,7 +451,7 @@ func (fm *FuzzyMatcher) isDomainMatch(query, url string) bool {
451451 return true
452452 }
453453 }
454-
454+
455455 return false
456456 }
457457
M internal/parser/parser.go +32 -32
@@ -1,12 +1,12 @@
11 package parser
22
33 import (
4- "fmt"
5- "strings"
6- "time"
7- neturl "net/url"
4+ "fmt"
5+ neturl "net/url"
6+ "strings"
7+ "time"
88
9- "github.com/bnema/dumber/internal/config"
9+ "github.com/bnema/dumber/internal/config"
1010 )
1111
1212 // ParseInput parses user input and returns a ParseResult with URL and metadata.
@@ -157,12 +157,12 @@ func (p *Parser) parseFallbackSearch(cleanInput, originalInput string, startTime
157157
158158 // processShortcut processes a search shortcut and builds the final URL.
159159 func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.SearchShortcut) (string, error) {
160- urlTemplate := shortcut.URL
160+ urlTemplate := shortcut.URL
161161
162- // Replace placeholder with query
163- q := neturl.QueryEscape(query)
164- finalURL := strings.ReplaceAll(urlTemplate, "{query}", q)
165- finalURL = strings.ReplaceAll(finalURL, "%s", q)
162+ // Replace placeholder with query
163+ q := neturl.QueryEscape(query)
164+ finalURL := strings.ReplaceAll(urlTemplate, "{query}", q)
165+ finalURL = strings.ReplaceAll(finalURL, "%s", q)
166166
167167 // Validate the resulting URL
168168 validator := NewURLValidator()
@@ -175,28 +175,28 @@ func (p *Parser) processShortcut(shortcutKey, query string, shortcut config.Sear
175175
176176 // buildSearchURL builds a search URL using the default search engine.
177177 func (p *Parser) buildSearchURL(searchEngine, query string) string {
178- q := neturl.QueryEscape(query)
179- // If no specific search engine, use Google as default
180- if searchEngine == "" {
181- defaultShortcut, exists := p.config.SearchShortcuts["g"]
182- if exists {
183- url := strings.ReplaceAll(defaultShortcut.URL, "{query}", q)
184- url = strings.ReplaceAll(url, "%s", q)
185- return url
186- }
187- // Fallback to Google if no "g" shortcut configured
188- return fmt.Sprintf("https://www.google.com/search?q=%s", q)
189- }
190-
191- // Use specified search engine
192- shortcut, exists := p.config.SearchShortcuts[searchEngine]
193- if !exists {
194- return fmt.Sprintf("https://www.google.com/search?q=%s", q)
195- }
196-
197- u := strings.ReplaceAll(shortcut.URL, "{query}", q)
198- u = strings.ReplaceAll(u, "%s", q)
199- return u
178+ q := neturl.QueryEscape(query)
179+ // If no specific search engine, use Google as default
180+ if searchEngine == "" {
181+ defaultShortcut, exists := p.config.SearchShortcuts["g"]
182+ if exists {
183+ url := strings.ReplaceAll(defaultShortcut.URL, "{query}", q)
184+ url = strings.ReplaceAll(url, "%s", q)
185+ return url
186+ }
187+ // Fallback to Google if no "g" shortcut configured
188+ return fmt.Sprintf("https://www.google.com/search?q=%s", q)
189+ }
190+
191+ // Use specified search engine
192+ shortcut, exists := p.config.SearchShortcuts[searchEngine]
193+ if !exists {
194+ return fmt.Sprintf("https://www.google.com/search?q=%s", q)
195+ }
196+
197+ u := strings.ReplaceAll(shortcut.URL, "{query}", q)
198+ u = strings.ReplaceAll(u, "%s", q)
199+ return u
200200 }
201201
202202 // FuzzySearchHistory performs fuzzy search on history and returns ranked matches.
M internal/parser/types.go +1 -1
@@ -4,8 +4,8 @@ package parser
44 import (
55 "time"
66
7- "github.com/bnema/dumber/internal/db"
87 "github.com/bnema/dumber/internal/config"
8+ "github.com/bnema/dumber/internal/db"
99 )
1010
1111 // InputType represents the type of user input detected.
M main.go +622 -483
@@ -1,26 +1,25 @@
11 package main
22
33 import (
4- "context"
5- "embed"
6- "encoding/json"
7- "fmt"
8- "mime"
9- "log"
10- "os"
11- "runtime"
12- neturl "net/url"
13- "path/filepath"
14- "strings"
15- "strconv"
16- "os/exec"
17-
18-
19- "github.com/bnema/dumber/internal/cli"
20- "github.com/bnema/dumber/internal/config"
21- "github.com/bnema/dumber/internal/db"
22- "github.com/bnema/dumber/services"
23- "github.com/bnema/dumber/pkg/webkit"
4+ "context"
5+ "embed"
6+ "encoding/json"
7+ "fmt"
8+ "log"
9+ "mime"
10+ neturl "net/url"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "runtime"
15+ "strconv"
16+ "strings"
17+
18+ "github.com/bnema/dumber/internal/cli"
19+ "github.com/bnema/dumber/internal/config"
20+ "github.com/bnema/dumber/internal/db"
21+ "github.com/bnema/dumber/pkg/webkit"
22+ "github.com/bnema/dumber/services"
2423 )
2524
2625 // Build information set via ldflags
@@ -37,8 +36,8 @@ func main() {
3736 return
3837 }
3938
40- // Otherwise run the GUI browser
41- runBrowser()
39+ // Otherwise run the GUI browser
40+ runBrowser()
4241 }
4342
4443 // shouldRunCLI determines if we should run in CLI mode based on arguments
@@ -107,7 +106,7 @@ func runCLI() {
107106 if err := rootCmd.Execute(); err != nil {
108107 fmt.Fprintf(os.Stderr, "Error: %v\n", err)
109108 os.Exit(1)
110- }
109+ }
111110 }
112111
113112 //go:embed frontend/dist
@@ -115,469 +114,609 @@ var assets embed.FS
115114
116115 // runBrowser launches a minimal native WebKit window and integrates services.
117116 func runBrowser() {
118- log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable())
119- // GTK requires all UI calls to run on the main OS thread
120- if webkit.IsNativeAvailable() {
121- runtime.LockOSThread()
122- defer runtime.UnlockOSThread()
123- }
124-
125- // Proactively harden media pipeline to avoid WebProcess crashes from buggy VAAPI/plugins.
126- // Can be disabled with DUMBER_MEDIA_SAFE=0
127- if os.Getenv("DUMBER_MEDIA_SAFE") != "0" {
128- // Demote VAAPI-related elements so they won't be auto-picked
129- if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" {
130- os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0")
131- }
132- // Provide a safe audio sink to avoid autoaudiosink failures
133- if os.Getenv("GST_AUDIO_SINK") == "" {
134- os.Setenv("GST_AUDIO_SINK", "fakesink")
135- }
136- // Optionally disable media stream features that can engage complex pipelines
137- if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" {
138- os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1")
139- }
140- log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)")
141- }
142-
143- if err := config.Init(); err != nil {
144- log.Fatalf("Failed to initialize config: %v", err)
145- }
146- cfg := config.Get()
147- log.Printf("Config initialized")
148-
149- // Detect keyboard layout/locale and hint to webkit layer
150- detectAndSetKeyboardLocale()
151-
152- // Initialize database
153- database, err := db.InitDB(cfg.Database.Path)
154- if err != nil {
155- log.Fatalf("Failed to initialize database: %v", err)
156- }
157- defer database.Close()
158- queries := db.New(database)
159- log.Printf("Database opened at %s", cfg.Database.Path)
160-
161- // Initialize services
162- parserService := services.NewParserService(cfg, queries)
163- browserService := services.NewBrowserService(cfg, queries)
164- // JS controls injection is disabled; using native shortcuts via GTK.
165-
166- // Track current URL for zoom persistence
167- // Initialize to the initial homepage so shortcuts work immediately
168- var currentURL string = "dumb://homepage"
169-
170- // Register custom scheme resolver for dumb://
171- webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) {
172- log.Printf("[scheme] request: %s", uri)
173- // Known forms:
174- // - dumb://homepage or dumb:homepage → index.html
175- // - dumb://app/index.html, dumb://app/<path> → serve from frontend/dist/<path>
176- // - dumb://<anything> without path → index.html
177- u, err := neturl.Parse(uri)
178- if err != nil || u.Scheme != "dumb" {
179- return "", nil, false
180- }
181- // API routes
182- if u.Host == "api" || strings.HasPrefix(u.Opaque, "api") || strings.HasPrefix(u.Path, "/api") {
183- // Normalize path for opaque or hierarchical forms
184- path := u.Path
185- if path == "" && u.Opaque != "" {
186- // e.g., dumb:api/config or dumb:api/history/recent
187- parts := strings.SplitN(u.Opaque, ":", 2)
188- if len(parts) == 2 {
189- path = "/" + parts[1]
190- }
191- }
192- if strings.HasPrefix(path, "/api/") { path = strings.TrimPrefix(path, "/api") }
193- if path == "/api" { path = "/" }
194- switch {
195- case strings.HasPrefix(path, "/rendering/status"):
196- log.Printf("[api] GET /rendering/status")
197- // Report configured rendering mode; runtime GPU state depends on WebKit internals
198- resp := struct{
199- Mode string `json:"mode"`
200- }{Mode: string(cfg.RenderingMode)}
201- b, _ := json.Marshal(resp)
202- return "application/json; charset=utf-8", b, true
203- case strings.HasPrefix(path, "/config"):
204- log.Printf("[api] GET /config")
205- // Build config info
206- cfgPath, _ := config.GetConfigFile()
207- info := struct {
208- ConfigPath string `json:"config_path"`
209- DatabasePath string `json:"database_path"`
210- SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
211- Appearance config.AppearanceConfig `json:"appearance"`
212- }{
213- ConfigPath: cfgPath,
214- DatabasePath: cfg.Database.Path,
215- SearchShortcuts: cfg.SearchShortcuts,
216- Appearance: cfg.Appearance,
217- }
218- b, _ := json.Marshal(info)
219- return "application/json; charset=utf-8", b, true
220- case strings.HasPrefix(path, "/history/recent"):
221- log.Printf("[api] GET /history/recent%s", u.RawQuery)
222- // Parse limit
223- q := u.Query()
224- limit := 50
225- if l := q.Get("limit"); l != "" {
226- if n, err := strconv.Atoi(l); err == nil && n > 0 {
227- limit = n
228- }
229- }
230- ctx := context.Background()
231- entries, err := browserService.GetRecentHistory(ctx, limit)
232- if err != nil {
233- return "application/json; charset=utf-8", []byte("[]"), true
234- }
235- b, _ := json.Marshal(entries)
236- return "application/json; charset=utf-8", b, true
237- case strings.HasPrefix(path, "/history/search"):
238- log.Printf("[api] GET /history/search%s", u.RawQuery)
239- q := u.Query()
240- query := q.Get("q")
241- limit := 50
242- if l := q.Get("limit"); l != "" {
243- if n, err := strconv.Atoi(l); err == nil && n > 0 {
244- limit = n
245- }
246- }
247- ctx := context.Background()
248- entries, err := browserService.SearchHistory(ctx, query, limit)
249- if err != nil {
250- return "application/json; charset=utf-8", []byte("[]"), true
251- }
252- b, _ := json.Marshal(entries)
253- return "application/json; charset=utf-8", b, true
254- case strings.HasPrefix(path, "/history/stats"):
255- log.Printf("[api] GET /history/stats")
256- ctx := context.Background()
257- stats, err := browserService.GetHistoryStats(ctx)
258- if err != nil { return "application/json; charset=utf-8", []byte("{}"), true }
259- b, _ := json.Marshal(stats)
260- return "application/json; charset=utf-8", b, true
261- default:
262- return "application/json; charset=utf-8", []byte("{}"), true
263- }
264- }
265- // Resolve target path inside embed FS
266- var rel string
267- if u.Opaque == "homepage" || (u.Host == "homepage" && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) {
268- rel = "index.html"
269- } else {
270- host := u.Host
271- p := strings.TrimPrefix(u.Path, "/")
272- if host == "app" && p == "" {
273- rel = "index.html"
274- } else if host == "app" {
275- rel = p
276- } else if host == "homepage" && p != "" {
277- // dumb://homepage/<asset>
278- rel = p
279- } else if p != "" {
280- rel = p
281- } else {
282- rel = "index.html"
283- }
284- }
285- log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path)
286- data, rerr := assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel)))
287- if rerr != nil {
288- log.Printf("[scheme] not found: %s", rel)
289- return "", nil, false
290- }
291- // Determine mime type
292- mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel)))
293- if mt == "" {
294- // Fallbacks
295- switch strings.ToLower(filepath.Ext(rel)) {
296- case ".js": mt = "application/javascript"
297- case ".css": mt = "text/css"
298- case ".svg": mt = "image/svg+xml"
299- case ".ico": mt = "image/x-icon"
300- default: mt = "text/html; charset=utf-8"
301- }
302- }
303- return mt, data, true
304- })
305-
306- // Create WebKit view
307- log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
308- dataDir, _ := config.GetDataDir()
309- stateDir, _ := config.GetStateDir()
310- webkitData := filepath.Join(dataDir, "webkit")
311- webkitCache := filepath.Join(stateDir, "webkit-cache")
312- _ = os.MkdirAll(webkitData, 0o755)
313- _ = os.MkdirAll(webkitCache, 0o755)
314- view, err := webkit.NewWebView(&webkit.Config{
315- InitialURL: "dumb://homepage",
316- ZoomDefault: 1.0,
317- EnableDeveloperExtras: true,
318- DataDir: webkitData,
319- CacheDir: webkitCache,
320- DefaultSansFont: cfg.Appearance.SansFont,
321- DefaultSerifFont: cfg.Appearance.SerifFont,
322- DefaultMonospaceFont: cfg.Appearance.MonospaceFont,
323- DefaultFontSize: cfg.Appearance.DefaultFontSize,
324- Rendering: webkit.RenderingConfig{Mode: string(cfg.RenderingMode)},
325- })
326- if err != nil {
327- log.Printf("Warning: failed to create WebView: %v", err)
328- } else {
329- browserService.AttachWebView(view)
330- // Use native window as title updater
331- if win := view.Window(); win != nil {
332- browserService.SetWindowTitleUpdater(win)
333- }
334- // Persist page titles to DB when they change
335- view.RegisterTitleChangedHandler(func(title string) {
336- ctx := context.Background()
337- url := view.GetCurrentURL()
338- if url != "" && title != "" {
339- if err := browserService.UpdatePageTitle(ctx, url, title); err != nil {
340- log.Printf("Warning: failed to update page title: %v", err)
341- }
342- }
343- })
344- // Track current URL changes and apply saved zoom
345- view.RegisterURIChangedHandler(func(u string) {
346- currentURL = u
347- if u == "" { return }
348- ctx := context.Background()
349- if z, err := browserService.GetZoomLevel(ctx, u); err == nil {
350- _ = view.SetZoom(z)
351- key := services.ZoomKeyForLog(u)
352- log.Printf("[zoom] loaded %.2f for %s", z, key)
353- }
354- })
355- // Persist zoom changes per-domain
356- view.RegisterZoomChangedHandler(func(level float64) {
357- url := view.GetCurrentURL()
358- if url == "" { return }
359- ctx := context.Background()
360- if err := browserService.SetZoomLevel(ctx, url, level); err != nil {
361- log.Printf("[zoom] failed to save level %.2f for %s: %v", level, url, err)
362- return
363- }
364- key := services.ZoomKeyForLog(url)
365- log.Printf("[zoom] saved %.2f for %s", level, key)
366- })
367- // Bridge UCM messages: navigate + history queries
368- view.RegisterScriptMessageHandler(func(payload string) {
369- type msg struct {
370- Type string `json:"type"`
371- URL string `json:"url"`
372- Q string `json:"q"`
373- Limit int `json:"limit"`
374- Value string `json:"value"`
375- // Wails fetch bridge
376- ID string `json:"id"`
377- Payload json.RawMessage `json:"payload"`
378- }
379- var m msg
380- if err := json.Unmarshal([]byte(payload), &m); err != nil { return }
381- switch m.Type {
382- case "navigate":
383- ctx := context.Background()
384- res, err := parserService.ParseInput(ctx, m.URL)
385- if err == nil {
386- currentURL = res.URL
387- browserService.Navigate(ctx, currentURL)
388- _ = view.LoadURL(currentURL)
389- if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil { _ = view.SetZoom(z) }
390- }
391- case "query":
392- ctx := context.Background()
393- lim := m.Limit
394- if lim <= 0 || lim > 25 { lim = 10 }
395- entries, err := browserService.SearchHistory(ctx, m.Q, lim)
396- if err != nil { return }
397- // Map to lightweight items
398- type item struct { URL string `json:"url"`; Favicon string `json:"favicon"` }
399- buildFavicon := func(raw string) string {
400- u, err := parserService.ParseInput(ctx, raw)
401- if err != nil || u.URL == "" { return "" }
402- parsed, perr := neturl.Parse(u.URL)
403- if perr != nil || parsed.Host == "" { return "" }
404- scheme := parsed.Scheme
405- if scheme == "" { scheme = "https" }
406- return scheme + "://" + parsed.Host + "/favicon.ico"
407- }
408- items := make([]item, 0, len(entries))
409- for _, e := range entries { items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)}) }
410- b, _ := json.Marshal(items)
411- // Update suggestions in page
412- _ = view.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")")
413- case "wails":
414- // Handle Wails runtime fetch bridge calls for homepage
415- // Payload contains { methodID, methodName?, args }
416- var p struct {
417- MethodID uint32 `json:"methodID"`
418- MethodName string `json:"methodName"`
419- Args json.RawMessage `json:"args"`
420- }
421- if err := json.Unmarshal(m.Payload, &p); err != nil { return }
422- // Only implement the IDs we need
423- switch p.MethodID {
424- case 3708519028: // BrowserService.GetRecentHistory(limit)
425- var args []interface{}
426- _ = json.Unmarshal(p.Args, &args)
427- limit := 50
428- if len(args) > 0 {
429- if f, ok := args[0].(float64); ok { limit = int(f) }
430- }
431- ctx := context.Background()
432- entries, err := browserService.GetRecentHistory(ctx, limit)
433- if err != nil { return }
434- resp, _ := json.Marshal(entries)
435- _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")")
436- case 4078533762: // BrowserService.GetSearchShortcuts()
437- ctx := context.Background()
438- shortcuts, err := browserService.GetSearchShortcuts(ctx)
439- if err != nil { return }
440- resp, _ := json.Marshal(shortcuts)
441- _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")")
442- default:
443- // Return empty JSON to avoid breaking UI
444- _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', '{}')")
445- }
446- case "theme":
447- if m.Value != "" { log.Printf("[theme] page reported color-scheme: %s", m.Value) }
448- }
449- })
450- // Apply initial zoom setting and log it before showing the window
451- {
452- ctx := context.Background()
453- u := view.GetCurrentURL()
454- if u == "" { u = "dumb://homepage" }
455- if z, zerr := browserService.GetZoomLevel(ctx, u); zerr == nil {
456- if err := view.SetZoom(z); err != nil {
457- log.Printf("Warning: failed to set initial zoom: %v", err)
458- } else {
459- key := services.ZoomKeyForLog(u)
460- log.Printf("[zoom] loaded %.2f for %s", z, key)
461- }
462- }
463- }
464-
465- log.Printf("Showing WebView window…")
466- if err := view.Show(); err != nil {
467- log.Printf("Warning: failed to show WebView: %v", err)
468- } else {
469- if !webkit.IsNativeAvailable() {
470- log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
471- }
472- }
473- }
474-
475- // Parse browse argument if present
476- if len(os.Args) >= 3 && os.Args[1] == "browse" {
477- log.Printf("Browse command detected: %s", os.Args[2])
478- ctx := context.Background()
479- result, err := parserService.ParseInput(ctx, os.Args[2])
480- if err == nil {
481- currentURL = result.URL
482- log.Printf("Parsed input → URL: %s", currentURL)
483- browserService.Navigate(ctx, currentURL)
484- if view != nil {
485- log.Printf("Loading URL in WebView: %s", currentURL)
486- if err := view.LoadURL(currentURL); err != nil {
487- log.Printf("Warning: failed to load URL: %v", err)
488- }
489- if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil {
490- if err := view.SetZoom(z); err != nil {
491- log.Printf("Warning: failed to set zoom: %v", err)
492- } else {
493- key := services.ZoomKeyForLog(currentURL)
494- log.Printf("[zoom] loaded %.2f for %s", z, key)
495- }
496- }
497- // No JS injection; native accelerators handle zoom/devtools.
498- }
499- }
500- }
501-
502- // Register basic keyboard shortcuts on the native view to preserve behavior
503- if view != nil {
504- // DevTools
505- _ = view.RegisterKeyboardShortcut("F12", func() { log.Printf("Shortcut: F12 (devtools)"); _ = view.ShowDevTools() })
506- // Omnibox (Ctrl+L): rely on injected script listener
507- _ = view.RegisterKeyboardShortcut("cmdorctrl+l", func() {
508- log.Printf("Shortcut: Omnibox toggle")
509- _ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()")
510- })
511- // Zoom handled natively in webkit package (built-in shortcuts)
512- }
513-
514- // Enter GTK main loop only when native backend is available.
515- if webkit.IsNativeAvailable() {
516- log.Printf("Entering GTK main loop…")
517- webkit.RunMainLoop()
518- log.Printf("GTK main loop exited")
519- } else {
520- log.Printf("Not entering GUI loop (non-CGO build)")
521- }
117+ log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable())
118+ // GTK requires all UI calls to run on the main OS thread
119+ if webkit.IsNativeAvailable() {
120+ runtime.LockOSThread()
121+ defer runtime.UnlockOSThread()
122+ }
123+
124+ // Proactively harden media pipeline to avoid WebProcess crashes from buggy VAAPI/plugins.
125+ // Can be disabled with DUMBER_MEDIA_SAFE=0
126+ if os.Getenv("DUMBER_MEDIA_SAFE") != "0" {
127+ // Demote VAAPI-related elements so they won't be auto-picked
128+ if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" {
129+ os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0")
130+ }
131+ // Provide a safe audio sink to avoid autoaudiosink failures
132+ if os.Getenv("GST_AUDIO_SINK") == "" {
133+ os.Setenv("GST_AUDIO_SINK", "fakesink")
134+ }
135+ // Optionally disable media stream features that can engage complex pipelines
136+ if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" {
137+ os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1")
138+ }
139+ log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)")
140+ }
141+
142+ if err := config.Init(); err != nil {
143+ log.Fatalf("Failed to initialize config: %v", err)
144+ }
145+ cfg := config.Get()
146+ log.Printf("Config initialized")
147+
148+ // Detect keyboard layout/locale and hint to webkit layer
149+ detectAndSetKeyboardLocale()
150+
151+ // Initialize database
152+ database, err := db.InitDB(cfg.Database.Path)
153+ if err != nil {
154+ log.Fatalf("Failed to initialize database: %v", err)
155+ }
156+ defer database.Close()
157+ queries := db.New(database)
158+ log.Printf("Database opened at %s", cfg.Database.Path)
159+
160+ // Initialize services
161+ parserService := services.NewParserService(cfg, queries)
162+ browserService := services.NewBrowserService(cfg, queries)
163+ // JS controls injection is disabled; using native shortcuts via GTK.
164+
165+ // Track current URL for zoom persistence
166+ // Initialize to the initial homepage so shortcuts work immediately
167+ var currentURL string = "dumb://homepage"
168+
169+ // Register custom scheme resolver for dumb://
170+ webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) {
171+ log.Printf("[scheme] request: %s", uri)
172+ // Known forms:
173+ // - dumb://homepage or dumb:homepage → index.html
174+ // - dumb://app/index.html, dumb://app/<path> → serve from frontend/dist/<path>
175+ // - dumb://<anything> without path → index.html
176+ u, err := neturl.Parse(uri)
177+ if err != nil || u.Scheme != "dumb" {
178+ return "", nil, false
179+ }
180+ // API routes
181+ if u.Host == "api" || strings.HasPrefix(u.Opaque, "api") || strings.HasPrefix(u.Path, "/api") {
182+ // Normalize path for opaque or hierarchical forms
183+ path := u.Path
184+ if path == "" && u.Opaque != "" {
185+ // e.g., dumb:api/config or dumb:api/history/recent
186+ parts := strings.SplitN(u.Opaque, ":", 2)
187+ if len(parts) == 2 {
188+ path = "/" + parts[1]
189+ }
190+ }
191+ if strings.HasPrefix(path, "/api/") {
192+ path = strings.TrimPrefix(path, "/api")
193+ }
194+ if path == "/api" {
195+ path = "/"
196+ }
197+ switch {
198+ case strings.HasPrefix(path, "/rendering/status"):
199+ log.Printf("[api] GET /rendering/status")
200+ // Report configured rendering mode; runtime GPU state depends on WebKit internals
201+ resp := struct {
202+ Mode string `json:"mode"`
203+ }{Mode: string(cfg.RenderingMode)}
204+ b, _ := json.Marshal(resp)
205+ return "application/json; charset=utf-8", b, true
206+ case strings.HasPrefix(path, "/config"):
207+ log.Printf("[api] GET /config")
208+ // Build config info
209+ cfgPath, _ := config.GetConfigFile()
210+ info := struct {
211+ ConfigPath string `json:"config_path"`
212+ DatabasePath string `json:"database_path"`
213+ SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
214+ Appearance config.AppearanceConfig `json:"appearance"`
215+ }{
216+ ConfigPath: cfgPath,
217+ DatabasePath: cfg.Database.Path,
218+ SearchShortcuts: cfg.SearchShortcuts,
219+ Appearance: cfg.Appearance,
220+ }
221+ b, _ := json.Marshal(info)
222+ return "application/json; charset=utf-8", b, true
223+ case strings.HasPrefix(path, "/history/recent"):
224+ log.Printf("[api] GET /history/recent%s", u.RawQuery)
225+ // Parse limit
226+ q := u.Query()
227+ limit := 50
228+ if l := q.Get("limit"); l != "" {
229+ if n, err := strconv.Atoi(l); err == nil && n > 0 {
230+ limit = n
231+ }
232+ }
233+ ctx := context.Background()
234+ entries, err := browserService.GetRecentHistory(ctx, limit)
235+ if err != nil {
236+ return "application/json; charset=utf-8", []byte("[]"), true
237+ }
238+ b, _ := json.Marshal(entries)
239+ return "application/json; charset=utf-8", b, true
240+ case strings.HasPrefix(path, "/history/search"):
241+ log.Printf("[api] GET /history/search%s", u.RawQuery)
242+ q := u.Query()
243+ query := q.Get("q")
244+ limit := 50
245+ if l := q.Get("limit"); l != "" {
246+ if n, err := strconv.Atoi(l); err == nil && n > 0 {
247+ limit = n
248+ }
249+ }
250+ ctx := context.Background()
251+ entries, err := browserService.SearchHistory(ctx, query, limit)
252+ if err != nil {
253+ return "application/json; charset=utf-8", []byte("[]"), true
254+ }
255+ b, _ := json.Marshal(entries)
256+ return "application/json; charset=utf-8", b, true
257+ case strings.HasPrefix(path, "/history/stats"):
258+ log.Printf("[api] GET /history/stats")
259+ ctx := context.Background()
260+ stats, err := browserService.GetHistoryStats(ctx)
261+ if err != nil {
262+ return "application/json; charset=utf-8", []byte("{}"), true
263+ }
264+ b, _ := json.Marshal(stats)
265+ return "application/json; charset=utf-8", b, true
266+ default:
267+ return "application/json; charset=utf-8", []byte("{}"), true
268+ }
269+ }
270+ // Resolve target path inside embed FS
271+ var rel string
272+ if u.Opaque == "homepage" || (u.Host == "homepage" && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) {
273+ rel = "index.html"
274+ } else {
275+ host := u.Host
276+ p := strings.TrimPrefix(u.Path, "/")
277+ if host == "app" && p == "" {
278+ rel = "index.html"
279+ } else if host == "app" {
280+ rel = p
281+ } else if host == "homepage" && p != "" {
282+ // dumb://homepage/<asset>
283+ rel = p
284+ } else if p != "" {
285+ rel = p
286+ } else {
287+ rel = "index.html"
288+ }
289+ }
290+ log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path)
291+ data, rerr := assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel)))
292+ if rerr != nil {
293+ log.Printf("[scheme] not found: %s", rel)
294+ return "", nil, false
295+ }
296+ // Determine mime type
297+ mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel)))
298+ if mt == "" {
299+ // Fallbacks
300+ switch strings.ToLower(filepath.Ext(rel)) {
301+ case ".js":
302+ mt = "application/javascript"
303+ case ".css":
304+ mt = "text/css"
305+ case ".svg":
306+ mt = "image/svg+xml"
307+ case ".ico":
308+ mt = "image/x-icon"
309+ default:
310+ mt = "text/html; charset=utf-8"
311+ }
312+ }
313+ return mt, data, true
314+ })
315+
316+ // Create WebKit view
317+ log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
318+ dataDir, _ := config.GetDataDir()
319+ stateDir, _ := config.GetStateDir()
320+ webkitData := filepath.Join(dataDir, "webkit")
321+ webkitCache := filepath.Join(stateDir, "webkit-cache")
322+ _ = os.MkdirAll(webkitData, 0o755)
323+ _ = os.MkdirAll(webkitCache, 0o755)
324+ view, err := webkit.NewWebView(&webkit.Config{
325+ InitialURL: "dumb://homepage",
326+ ZoomDefault: 1.0,
327+ EnableDeveloperExtras: true,
328+ DataDir: webkitData,
329+ CacheDir: webkitCache,
330+ DefaultSansFont: cfg.Appearance.SansFont,
331+ DefaultSerifFont: cfg.Appearance.SerifFont,
332+ DefaultMonospaceFont: cfg.Appearance.MonospaceFont,
333+ DefaultFontSize: cfg.Appearance.DefaultFontSize,
334+ Rendering: webkit.RenderingConfig{Mode: string(cfg.RenderingMode)},
335+ Memory: readMemoryConfigFromEnv(),
336+ })
337+ if err != nil {
338+ log.Printf("Warning: failed to create WebView: %v", err)
339+ } else {
340+ browserService.AttachWebView(view)
341+ // Use native window as title updater
342+ if win := view.Window(); win != nil {
343+ browserService.SetWindowTitleUpdater(win)
344+ }
345+ // Persist page titles to DB when they change
346+ view.RegisterTitleChangedHandler(func(title string) {
347+ ctx := context.Background()
348+ url := view.GetCurrentURL()
349+ if url != "" && title != "" {
350+ if err := browserService.UpdatePageTitle(ctx, url, title); err != nil {
351+ log.Printf("Warning: failed to update page title: %v", err)
352+ }
353+ }
354+ })
355+ // Track current URL changes and apply saved zoom
356+ view.RegisterURIChangedHandler(func(u string) {
357+ currentURL = u
358+ if u == "" {
359+ return
360+ }
361+ ctx := context.Background()
362+ if z, err := browserService.GetZoomLevel(ctx, u); err == nil {
363+ _ = view.SetZoom(z)
364+ key := services.ZoomKeyForLog(u)
365+ log.Printf("[zoom] loaded %.2f for %s", z, key)
366+ }
367+ })
368+ // Persist zoom changes per-domain
369+ view.RegisterZoomChangedHandler(func(level float64) {
370+ url := view.GetCurrentURL()
371+ if url == "" {
372+ return
373+ }
374+ ctx := context.Background()
375+ if err := browserService.SetZoomLevel(ctx, url, level); err != nil {
376+ log.Printf("[zoom] failed to save level %.2f for %s: %v", level, url, err)
377+ return
378+ }
379+ key := services.ZoomKeyForLog(url)
380+ log.Printf("[zoom] saved %.2f for %s", level, key)
381+ })
382+ // Bridge UCM messages: navigate + history queries
383+ view.RegisterScriptMessageHandler(func(payload string) {
384+ type msg struct {
385+ Type string `json:"type"`
386+ URL string `json:"url"`
387+ Q string `json:"q"`
388+ Limit int `json:"limit"`
389+ Value string `json:"value"`
390+ // Wails fetch bridge
391+ ID string `json:"id"`
392+ Payload json.RawMessage `json:"payload"`
393+ }
394+ var m msg
395+ if err := json.Unmarshal([]byte(payload), &m); err != nil {
396+ return
397+ }
398+ switch m.Type {
399+ case "navigate":
400+ ctx := context.Background()
401+ res, err := parserService.ParseInput(ctx, m.URL)
402+ if err == nil {
403+ currentURL = res.URL
404+ browserService.Navigate(ctx, currentURL)
405+ _ = view.LoadURL(currentURL)
406+ if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil {
407+ _ = view.SetZoom(z)
408+ }
409+ }
410+ case "query":
411+ ctx := context.Background()
412+ lim := m.Limit
413+ if lim <= 0 || lim > 25 {
414+ lim = 10
415+ }
416+ entries, err := browserService.SearchHistory(ctx, m.Q, lim)
417+ if err != nil {
418+ return
419+ }
420+ // Map to lightweight items
421+ type item struct {
422+ URL string `json:"url"`
423+ Favicon string `json:"favicon"`
424+ }
425+ buildFavicon := func(raw string) string {
426+ u, err := parserService.ParseInput(ctx, raw)
427+ if err != nil || u.URL == "" {
428+ return ""
429+ }
430+ parsed, perr := neturl.Parse(u.URL)
431+ if perr != nil || parsed.Host == "" {
432+ return ""
433+ }
434+ scheme := parsed.Scheme
435+ if scheme == "" {
436+ scheme = "https"
437+ }
438+ return scheme + "://" + parsed.Host + "/favicon.ico"
439+ }
440+ items := make([]item, 0, len(entries))
441+ for _, e := range entries {
442+ items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)})
443+ }
444+ b, _ := json.Marshal(items)
445+ // Update suggestions in page
446+ _ = view.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")")
447+ case "wails":
448+ // Handle Wails runtime fetch bridge calls for homepage
449+ // Payload contains { methodID, methodName?, args }
450+ var p struct {
451+ MethodID uint32 `json:"methodID"`
452+ MethodName string `json:"methodName"`
453+ Args json.RawMessage `json:"args"`
454+ }
455+ if err := json.Unmarshal(m.Payload, &p); err != nil {
456+ return
457+ }
458+ // Only implement the IDs we need
459+ switch p.MethodID {
460+ case 3708519028: // BrowserService.GetRecentHistory(limit)
461+ var args []interface{}
462+ _ = json.Unmarshal(p.Args, &args)
463+ limit := 50
464+ if len(args) > 0 {
465+ if f, ok := args[0].(float64); ok {
466+ limit = int(f)
467+ }
468+ }
469+ ctx := context.Background()
470+ entries, err := browserService.GetRecentHistory(ctx, limit)
471+ if err != nil {
472+ return
473+ }
474+ resp, _ := json.Marshal(entries)
475+ _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")")
476+ case 4078533762: // BrowserService.GetSearchShortcuts()
477+ ctx := context.Background()
478+ shortcuts, err := browserService.GetSearchShortcuts(ctx)
479+ if err != nil {
480+ return
481+ }
482+ resp, _ := json.Marshal(shortcuts)
483+ _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")")
484+ default:
485+ // Return empty JSON to avoid breaking UI
486+ _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', '{}')")
487+ }
488+ case "theme":
489+ if m.Value != "" {
490+ log.Printf("[theme] page reported color-scheme: %s", m.Value)
491+ }
492+ }
493+ })
494+ // Apply initial zoom setting and log it before showing the window
495+ {
496+ ctx := context.Background()
497+ u := view.GetCurrentURL()
498+ if u == "" {
499+ u = "dumb://homepage"
500+ }
501+ if z, zerr := browserService.GetZoomLevel(ctx, u); zerr == nil {
502+ if err := view.SetZoom(z); err != nil {
503+ log.Printf("Warning: failed to set initial zoom: %v", err)
504+ } else {
505+ key := services.ZoomKeyForLog(u)
506+ log.Printf("[zoom] loaded %.2f for %s", z, key)
507+ }
508+ }
509+ }
510+
511+ log.Printf("Showing WebView window…")
512+ if err := view.Show(); err != nil {
513+ log.Printf("Warning: failed to show WebView: %v", err)
514+ } else {
515+ if !webkit.IsNativeAvailable() {
516+ log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
517+ }
518+ }
519+ }
520+
521+ // Parse browse argument if present
522+ if len(os.Args) >= 3 && os.Args[1] == "browse" {
523+ log.Printf("Browse command detected: %s", os.Args[2])
524+ ctx := context.Background()
525+ result, err := parserService.ParseInput(ctx, os.Args[2])
526+ if err == nil {
527+ currentURL = result.URL
528+ log.Printf("Parsed input → URL: %s", currentURL)
529+ browserService.Navigate(ctx, currentURL)
530+ if view != nil {
531+ log.Printf("Loading URL in WebView: %s", currentURL)
532+ if err := view.LoadURL(currentURL); err != nil {
533+ log.Printf("Warning: failed to load URL: %v", err)
534+ }
535+ if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil {
536+ if err := view.SetZoom(z); err != nil {
537+ log.Printf("Warning: failed to set zoom: %v", err)
538+ } else {
539+ key := services.ZoomKeyForLog(currentURL)
540+ log.Printf("[zoom] loaded %.2f for %s", z, key)
541+ }
542+ }
543+ // No JS injection; native accelerators handle zoom/devtools.
544+ }
545+ }
546+ }
547+
548+ // Register basic keyboard shortcuts on the native view to preserve behavior
549+ if view != nil {
550+ // DevTools
551+ _ = view.RegisterKeyboardShortcut("F12", func() { log.Printf("Shortcut: F12 (devtools)"); _ = view.ShowDevTools() })
552+ // Omnibox (Ctrl+L): rely on injected script listener
553+ _ = view.RegisterKeyboardShortcut("cmdorctrl+l", func() {
554+ log.Printf("Shortcut: Omnibox toggle")
555+ _ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()")
556+ })
557+ // Zoom handled natively in webkit package (built-in shortcuts)
558+ }
559+
560+ // Enter GTK main loop only when native backend is available.
561+ if webkit.IsNativeAvailable() {
562+ log.Printf("Entering GTK main loop…")
563+ webkit.RunMainLoop()
564+ log.Printf("GTK main loop exited")
565+ } else {
566+ log.Printf("Not entering GUI loop (non-CGO build)")
567+ }
568+}
569+
570+// readMemoryConfigFromEnv reads optional memory optimization knobs from environment variables.
571+// Defaults favor performance; only applied when env vars are set.
572+func readMemoryConfigFromEnv() webkit.MemoryConfig {
573+ var mc webkit.MemoryConfig
574+
575+ // Cache model (document_viewer | web_browser | primary_web_browser)
576+ if v := os.Getenv("DUMBER_CACHE_MODEL"); v != "" {
577+ switch strings.ToLower(v) {
578+ case "document_viewer", "documentviewer", "doc":
579+ mc.CacheModel = webkit.CacheModelDocumentViewer
580+ case "primary_web_browser", "primary", "primarywebbrowser":
581+ mc.CacheModel = webkit.CacheModelPrimaryWebBrowser
582+ default:
583+ mc.CacheModel = webkit.CacheModelWebBrowser
584+ }
585+ }
586+
587+ // Page cache toggle (default true, only override if provided)
588+ if v := os.Getenv("DUMBER_ENABLE_PAGE_CACHE"); v != "" {
589+ mc.EnablePageCache = v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
590+ } else {
591+ mc.EnablePageCache = true
592+ }
593+
594+ // Memory limit (MB)
595+ if v := os.Getenv("DUMBER_MEMORY_LIMIT_MB"); v != "" {
596+ if n, err := strconv.Atoi(v); err == nil && n >= 0 {
597+ mc.MemoryLimitMB = n
598+ }
599+ }
600+
601+ // Thresholds
602+ if v := os.Getenv("DUMBER_MEMORY_CONSERVATIVE"); v != "" {
603+ if f, err := strconv.ParseFloat(v, 64); err == nil {
604+ mc.ConservativeThreshold = f
605+ }
606+ }
607+ if v := os.Getenv("DUMBER_MEMORY_STRICT"); v != "" {
608+ if f, err := strconv.ParseFloat(v, 64); err == nil {
609+ mc.StrictThreshold = f
610+ }
611+ }
612+ if v := os.Getenv("DUMBER_MEMORY_KILL"); v != "" {
613+ if f, err := strconv.ParseFloat(v, 64); err == nil {
614+ mc.KillThreshold = f
615+ }
616+ }
617+ if v := os.Getenv("DUMBER_MEMORY_POLL_INTERVAL"); v != "" {
618+ if f, err := strconv.ParseFloat(v, 64); err == nil {
619+ mc.PollIntervalSeconds = f
620+ }
621+ }
622+
623+ // Periodic GC (seconds)
624+ if v := os.Getenv("DUMBER_GC_INTERVAL"); v != "" {
625+ if n, err := strconv.Atoi(v); err == nil && n >= 0 {
626+ mc.EnableGCInterval = n
627+ }
628+ }
629+
630+ // Recycle after N page loads
631+ if v := os.Getenv("DUMBER_RECYCLE_THRESHOLD"); v != "" {
632+ if n, err := strconv.Atoi(v); err == nil && n >= 0 {
633+ mc.ProcessRecycleThreshold = n
634+ }
635+ }
636+
637+ // Enable memory monitoring logs
638+ if v := os.Getenv("DUMBER_ENABLE_MEMORY_MONITORING"); v != "" {
639+ mc.EnableMemoryMonitoring = v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
640+ }
641+
642+ return mc
522643 }
523644
524645 // detectAndSetKeyboardLocale attempts to determine the current keyboard layout
525646 // and hints it to the webkit input layer for accelerator compatibility.
526647 func detectAndSetKeyboardLocale() {
527- // 0) Explicit override
528- locale := os.Getenv("DUMBER_KEYBOARD_LOCALE")
529- if locale == "" {
530- locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix
531- }
532- // 1) Environment
533- if locale == "" { locale = os.Getenv("LC_ALL") }
534- if locale == "" { locale = os.Getenv("LANG") }
535- if locale == "" { locale = os.Getenv("LC_CTYPE") }
536- // Trim variants
537- if i := strings.IndexByte(locale, '.'); i > 0 { locale = locale[:i] }
538- if i := strings.IndexByte(locale, '@'); i > 0 { locale = locale[:i] }
539- if i := strings.IndexByte(locale, '_'); i > 0 { locale = locale[:i] }
540-
541- // 2) Try XKB env override
542- if locale == "" {
543- locale = os.Getenv("XKB_DEFAULT_LAYOUT")
544- }
545-
546- // 3) Best-effort probe of setxkbmap/localectl (non-fatal)
547- if locale == "" {
548- if out, err := exec.Command("localectl", "status").Output(); err == nil {
549- s := string(out)
550- for _, line := range strings.Split(s, "\n") {
551- line = strings.TrimSpace(line)
552- if strings.HasPrefix(strings.ToLower(line), "x11 layout:") || strings.HasPrefix(strings.ToLower(line), "keyboard layout:") {
553- parts := strings.SplitN(line, ":", 2)
554- if len(parts) == 2 {
555- cand := strings.TrimSpace(parts[1])
556- if cand != "" { locale = cand }
557- }
558- break
559- }
560- }
561- }
562- }
563- if locale == "" {
564- if out, err := exec.Command("setxkbmap", "-query").Output(); err == nil {
565- s := string(out)
566- for _, line := range strings.Split(s, "\n") {
567- line = strings.TrimSpace(line)
568- if strings.HasPrefix(strings.ToLower(line), "layout:") {
569- parts := strings.SplitN(line, ":", 2)
570- if len(parts) == 2 {
571- cand := strings.TrimSpace(parts[1])
572- if cand != "" { locale = cand }
573- }
574- break
575- }
576- }
577- }
578- }
579-
580- if locale == "" { locale = "en" }
581- // No layout-specific remaps; log for diagnostics only
582- log.Printf("[locale] keyboard locale detected: %s", locale)
648+ // 0) Explicit override
649+ locale := os.Getenv("DUMBER_KEYBOARD_LOCALE")
650+ if locale == "" {
651+ locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix
652+ }
653+ // 1) Environment
654+ if locale == "" {
655+ locale = os.Getenv("LC_ALL")
656+ }
657+ if locale == "" {
658+ locale = os.Getenv("LANG")
659+ }
660+ if locale == "" {
661+ locale = os.Getenv("LC_CTYPE")
662+ }
663+ // Trim variants
664+ if i := strings.IndexByte(locale, '.'); i > 0 {
665+ locale = locale[:i]
666+ }
667+ if i := strings.IndexByte(locale, '@'); i > 0 {
668+ locale = locale[:i]
669+ }
670+ if i := strings.IndexByte(locale, '_'); i > 0 {
671+ locale = locale[:i]
672+ }
673+
674+ // 2) Try XKB env override
675+ if locale == "" {
676+ locale = os.Getenv("XKB_DEFAULT_LAYOUT")
677+ }
678+
679+ // 3) Best-effort probe of setxkbmap/localectl (non-fatal)
680+ if locale == "" {
681+ if out, err := exec.Command("localectl", "status").Output(); err == nil {
682+ s := string(out)
683+ for _, line := range strings.Split(s, "\n") {
684+ line = strings.TrimSpace(line)
685+ if strings.HasPrefix(strings.ToLower(line), "x11 layout:") || strings.HasPrefix(strings.ToLower(line), "keyboard layout:") {
686+ parts := strings.SplitN(line, ":", 2)
687+ if len(parts) == 2 {
688+ cand := strings.TrimSpace(parts[1])
689+ if cand != "" {
690+ locale = cand
691+ }
692+ }
693+ break
694+ }
695+ }
696+ }
697+ }
698+ if locale == "" {
699+ if out, err := exec.Command("setxkbmap", "-query").Output(); err == nil {
700+ s := string(out)
701+ for _, line := range strings.Split(s, "\n") {
702+ line = strings.TrimSpace(line)
703+ if strings.HasPrefix(strings.ToLower(line), "layout:") {
704+ parts := strings.SplitN(line, ":", 2)
705+ if len(parts) == 2 {
706+ cand := strings.TrimSpace(parts[1])
707+ if cand != "" {
708+ locale = cand
709+ }
710+ }
711+ break
712+ }
713+ }
714+ }
715+ }
716+
717+ if locale == "" {
718+ locale = "en"
719+ }
720+ // No layout-specific remaps; log for diagnostics only
721+ log.Printf("[locale] keyboard locale detected: %s", locale)
583722 }
M pkg/webkit/build_flags_cgo.go +0 -1
@@ -4,4 +4,3 @@ package webkit
44
55 // IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in.
66 func IsNativeAvailable() bool { return true }
7-
M pkg/webkit/build_flags_stub.go +0 -1
@@ -5,4 +5,3 @@ package webkit
55 // IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in.
66 // In non-CGO builds, this returns false and WebView methods are logical no-ops.
77 func IsNativeAvailable() bool { return false }
8-
M pkg/webkit/devtools_cgo.go +18 -10
@@ -9,17 +9,25 @@ package webkit
99 import "C"
1010
1111 func (w *WebView) ShowDevTools() error {
12- if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented }
13- insp := C.webkit_web_view_get_inspector(w.native.wv)
14- if insp == nil { return ErrNotImplemented }
15- C.webkit_web_inspector_show(insp)
16- return nil
12+ if w == nil || w.native == nil || w.native.wv == nil {
13+ return ErrNotImplemented
14+ }
15+ insp := C.webkit_web_view_get_inspector(w.native.wv)
16+ if insp == nil {
17+ return ErrNotImplemented
18+ }
19+ C.webkit_web_inspector_show(insp)
20+ return nil
1721 }
1822
1923 func (w *WebView) CloseDevTools() error {
20- if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented }
21- insp := C.webkit_web_view_get_inspector(w.native.wv)
22- if insp == nil { return ErrNotImplemented }
23- C.webkit_web_inspector_close(insp)
24- return nil
24+ if w == nil || w.native == nil || w.native.wv == nil {
25+ return ErrNotImplemented
26+ }
27+ insp := C.webkit_web_view_get_inspector(w.native.wv)
28+ if insp == nil {
29+ return ErrNotImplemented
30+ }
31+ C.webkit_web_inspector_close(insp)
32+ return nil
2533 }
M pkg/webkit/keyboard.go +17 -17
@@ -6,17 +6,17 @@ import "fmt"
66
77 // RegisterKeyboardShortcut binds a global or window-scoped accelerator to a callback.
88 func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error {
9- if w == nil || w.destroyed {
10- return ErrNotImplemented
11- }
12- if accel == "" || callback == nil {
13- return fmt.Errorf("invalid shortcut registration")
14- }
15- // Placeholder: in real impl, bind GTK accelerator; for now, store for tests.
16- if shortcuts := getShortcutRegistry(w); shortcuts != nil {
17- shortcuts[accel] = callback
18- }
19- return nil
9+ if w == nil || w.destroyed {
10+ return ErrNotImplemented
11+ }
12+ if accel == "" || callback == nil {
13+ return fmt.Errorf("invalid shortcut registration")
14+ }
15+ // Placeholder: in real impl, bind GTK accelerator; for now, store for tests.
16+ if shortcuts := getShortcutRegistry(w); shortcuts != nil {
17+ shortcuts[accel] = callback
18+ }
19+ return nil
2020 }
2121
2222 // internal registry per view while no GTK binding exists.
@@ -25,12 +25,12 @@ type shortcutRegistry map[string]func()
2525 var viewShortcuts = make(map[*WebView]shortcutRegistry)
2626
2727 func getShortcutRegistry(w *WebView) shortcutRegistry {
28- reg, ok := viewShortcuts[w]
29- if !ok {
30- reg = make(shortcutRegistry)
31- viewShortcuts[w] = reg
32- }
33- return reg
28+ reg, ok := viewShortcuts[w]
29+ if !ok {
30+ reg = make(shortcutRegistry)
31+ viewShortcuts[w] = reg
32+ }
33+ return reg
3434 }
3535
3636 // 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) {
7878 import "C"
7979
8080 import (
81- "fmt"
82- "log"
83- "sync/atomic"
84- "sync"
85- "time"
81+ "fmt"
82+ "log"
83+ "sync"
84+ "sync/atomic"
85+ "time"
8686 )
8787
8888 // Registry of accelerators per view id.
8989 type shortcutRegistry map[string]func()
9090
9191 var (
92- regMu sync.RWMutex
93- viewShortcuts = make(map[uintptr]shortcutRegistry)
94- viewIDCounter uint64
95- viewByID = make(map[uintptr]*WebView)
96- lastKeyTime = make(map[uintptr]map[string]time.Time)
92+ regMu sync.RWMutex
93+ viewShortcuts = make(map[uintptr]shortcutRegistry)
94+ viewIDCounter uint64
95+ viewByID = make(map[uintptr]*WebView)
96+ lastKeyTime = make(map[uintptr]map[string]time.Time)
9797 )
9898
9999 // Locale hint function removed; no layout-specific remaps.
100100
101101 // RegisterKeyboardShortcut registers a callback under an accelerator string.
102102 func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error {
103- if w == nil || w.destroyed {
104- return ErrNotImplemented
105- }
106- if accel == "" || callback == nil {
107- return fmt.Errorf("invalid shortcut registration")
108- }
109- regMu.Lock()
110- if _, ok := viewShortcuts[w.id]; !ok {
111- viewShortcuts[w.id] = make(shortcutRegistry)
112- }
113- viewShortcuts[w.id][accel] = callback
114- regMu.Unlock()
115- return nil
103+ if w == nil || w.destroyed {
104+ return ErrNotImplemented
105+ }
106+ if accel == "" || callback == nil {
107+ return fmt.Errorf("invalid shortcut registration")
108+ }
109+ regMu.Lock()
110+ if _, ok := viewShortcuts[w.id]; !ok {
111+ viewShortcuts[w.id] = make(shortcutRegistry)
112+ }
113+ viewShortcuts[w.id][accel] = callback
114+ regMu.Unlock()
115+ return nil
116116 }
117117
118118 //export goOnKeyPress
119119 func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) {
120- // Normalize and dispatch
121- uid := uintptr(id)
122- kv := uint(keyval)
123- st := uint(state)
124- dispatchAccelerator(uid, kv, st)
120+ // Normalize and dispatch
121+ uid := uintptr(id)
122+ kv := uint(keyval)
123+ st := uint(state)
124+ dispatchAccelerator(uid, kv, st)
125125 }
126126
127127 func dispatchAccelerator(uid uintptr, keyval uint, state uint) {
128- ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0
129- alt := (state & uint(C.GDK_ALT_MASK)) != 0
130- // Map keyval to string names
131- var keyName string
132- switch keyval {
133- case uint(C.GDK_KEY_minus), uint(C.GDK_KEY_KP_Subtract), uint(C.GDK_KEY_underscore):
134- // Normalize minus; log raw detection for diagnostics
135- log.Printf("[accelerator-raw] minus key detected ctrl=%v alt=%v keyval=0x%x", ctrl, alt, keyval)
136- keyName = "-"
137- case uint(C.GDK_KEY_equal), uint(C.GDK_KEY_KP_Add):
138- keyName = "="
139- case uint(C.GDK_KEY_0), uint(C.GDK_KEY_KP_0):
140- keyName = "0"
141- case uint(C.GDK_KEY_Left):
142- keyName = "ArrowLeft"
143- case uint(C.GDK_KEY_Right):
144- keyName = "ArrowRight"
145- case uint(C.GDK_KEY_F12):
146- keyName = "F12"
147- default:
148- // If Control held, log unknown keyval for diagnostics
149- if ctrl {
150- log.Printf("[accelerator-miss] ctrl keyval=0x%x", keyval)
151- }
152- return
153- }
128+ ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0
129+ alt := (state & uint(C.GDK_ALT_MASK)) != 0
130+ // Map keyval to string names
131+ var keyName string
132+ switch keyval {
133+ case uint(C.GDK_KEY_minus), uint(C.GDK_KEY_KP_Subtract), uint(C.GDK_KEY_underscore):
134+ // Normalize minus; log raw detection for diagnostics
135+ log.Printf("[accelerator-raw] minus key detected ctrl=%v alt=%v keyval=0x%x", ctrl, alt, keyval)
136+ keyName = "-"
137+ case uint(C.GDK_KEY_equal), uint(C.GDK_KEY_KP_Add):
138+ keyName = "="
139+ case uint(C.GDK_KEY_0), uint(C.GDK_KEY_KP_0):
140+ keyName = "0"
141+ case uint(C.GDK_KEY_Left):
142+ keyName = "ArrowLeft"
143+ case uint(C.GDK_KEY_Right):
144+ keyName = "ArrowRight"
145+ case uint(C.GDK_KEY_F12):
146+ keyName = "F12"
147+ default:
148+ // If Control held, log unknown keyval for diagnostics
149+ if ctrl {
150+ log.Printf("[accelerator-miss] ctrl keyval=0x%x", keyval)
151+ }
152+ return
153+ }
154154
155- // Candidate accelerator strings in order
156- candidates := []string{keyName}
157- if ctrl {
158- // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=)
159- candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...)
160- // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-)
161- candidates = append([]string{"cmdorctrl" + keyName, "ctrl" + keyName}, candidates...)
162- }
163- if alt {
164- candidates = append([]string{"alt+" + keyName}, candidates...)
165- }
155+ // Candidate accelerator strings in order
156+ candidates := []string{keyName}
157+ if ctrl {
158+ // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=)
159+ candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...)
160+ // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-)
161+ candidates = append([]string{"cmdorctrl" + keyName, "ctrl" + keyName}, candidates...)
162+ }
163+ if alt {
164+ candidates = append([]string{"alt+" + keyName}, candidates...)
165+ }
166166
167- regMu.RLock()
168- reg := viewShortcuts[uid]
169- regMu.RUnlock()
170- if reg == nil {
171- return
172- }
173- for _, name := range candidates {
174- if cb, ok := reg[name]; ok {
175- // Debounce duplicates within 120ms for the same view+key
176- regMu.Lock()
177- if _, ok := lastKeyTime[uid]; !ok { lastKeyTime[uid] = make(map[string]time.Time) }
178- last := lastKeyTime[uid][name]
179- now := time.Now()
180- if now.Sub(last) < 120*time.Millisecond {
181- // Skip duplicate
182- regMu.Unlock()
183- return
184- }
185- lastKeyTime[uid][name] = now
186- regMu.Unlock()
187- log.Printf("[accelerator] %s", name)
188- cb()
189- break
190- }
191- }
167+ regMu.RLock()
168+ reg := viewShortcuts[uid]
169+ regMu.RUnlock()
170+ if reg == nil {
171+ return
172+ }
173+ for _, name := range candidates {
174+ if cb, ok := reg[name]; ok {
175+ // Debounce duplicates within 120ms for the same view+key
176+ regMu.Lock()
177+ if _, ok := lastKeyTime[uid]; !ok {
178+ lastKeyTime[uid] = make(map[string]time.Time)
179+ }
180+ last := lastKeyTime[uid][name]
181+ now := time.Now()
182+ if now.Sub(last) < 120*time.Millisecond {
183+ // Skip duplicate
184+ regMu.Unlock()
185+ return
186+ }
187+ lastKeyTime[uid][name] = now
188+ regMu.Unlock()
189+ log.Printf("[accelerator] %s", name)
190+ cb()
191+ break
192+ }
193+ }
192194 }
193195
194196 func nextViewID() uintptr {
195- return uintptr(atomic.AddUint64(&viewIDCounter, 1))
197+ return uintptr(atomic.AddUint64(&viewIDCounter, 1))
196198 }
197199
198200 func registerView(id uintptr, w *WebView) {
199- regMu.Lock()
200- viewByID[id] = w
201- regMu.Unlock()
201+ regMu.Lock()
202+ viewByID[id] = w
203+ regMu.Unlock()
202204 }
203205
204206 func unregisterView(id uintptr) {
205- regMu.Lock()
206- delete(viewByID, id)
207- delete(viewShortcuts, id)
208- regMu.Unlock()
207+ regMu.Lock()
208+ delete(viewByID, id)
209+ delete(viewShortcuts, id)
210+ regMu.Unlock()
209211 }
210212
211213 // AttachKeyboardControllers attaches GTK4 key and mouse controllers to the view widget.
212214 func AttachKeyboardControllers(w *WebView) {
213- if w == nil || w.native == nil || w.native.view == nil {
214- return
215- }
216- C.attach_key_controller(w.native.view, C.ulong(w.id))
217- if w.native.win != nil {
218- C.attach_key_controller(w.native.win, C.ulong(w.id))
219- }
220- C.attach_mouse_gesture(w.native.view, C.ulong(w.id))
221- // Legacy controller captures raw button presses (8/9) for back/forward reliably
222- C.attach_mouse_legacy(w.native.view, C.ulong(w.id))
223- log.Printf("[input] GTK4 key/mouse controllers attached")
215+ if w == nil || w.native == nil || w.native.view == nil {
216+ return
217+ }
218+ C.attach_key_controller(w.native.view, C.ulong(w.id))
219+ if w.native.win != nil {
220+ C.attach_key_controller(w.native.win, C.ulong(w.id))
221+ }
222+ C.attach_mouse_gesture(w.native.view, C.ulong(w.id))
223+ // Legacy controller captures raw button presses (8/9) for back/forward reliably
224+ C.attach_mouse_legacy(w.native.view, C.ulong(w.id))
225+ log.Printf("[input] GTK4 key/mouse controllers attached")
224226 }
225227
226228 //export goOnScroll
227229 func goOnScroll(id C.ulong, dx C.double, dy C.double, state C.GdkModifierType) {
228- uid := uintptr(id)
229- regMu.RLock()
230- vw := viewByID[uid]
231- regMu.RUnlock()
232- if vw == nil { return }
233- // Ctrl+scroll zooms
234- if (uint(state) & uint(C.GDK_CONTROL_MASK)) != 0 {
235- if float64(dy) < 0 {
236- // scroll up -> zoom in
237- nz := vw.zoom
238- if nz <= 0 { nz = 1.0 }
239- nz *= 1.1
240- if nz < 0.25 { nz = 0.25 }
241- if nz > 5.0 { nz = 5.0 }
242- _ = vw.SetZoom(nz)
243- } else if float64(dy) > 0 {
244- // scroll down -> zoom out
245- nz := vw.zoom
246- if nz <= 0 { nz = 1.0 }
247- nz /= 1.1
248- if nz < 0.25 { nz = 0.25 }
249- if nz > 5.0 { nz = 5.0 }
250- _ = vw.SetZoom(nz)
251- }
252- }
230+ uid := uintptr(id)
231+ regMu.RLock()
232+ vw := viewByID[uid]
233+ regMu.RUnlock()
234+ if vw == nil {
235+ return
236+ }
237+ // Ctrl+scroll zooms
238+ if (uint(state) & uint(C.GDK_CONTROL_MASK)) != 0 {
239+ if float64(dy) < 0 {
240+ // scroll up -> zoom in
241+ nz := vw.zoom
242+ if nz <= 0 {
243+ nz = 1.0
244+ }
245+ nz *= 1.1
246+ if nz < 0.25 {
247+ nz = 0.25
248+ }
249+ if nz > 5.0 {
250+ nz = 5.0
251+ }
252+ _ = vw.SetZoom(nz)
253+ } else if float64(dy) > 0 {
254+ // scroll down -> zoom out
255+ nz := vw.zoom
256+ if nz <= 0 {
257+ nz = 1.0
258+ }
259+ nz /= 1.1
260+ if nz < 0.25 {
261+ nz = 0.25
262+ }
263+ if nz > 5.0 {
264+ nz = 5.0
265+ }
266+ _ = vw.SetZoom(nz)
267+ }
268+ }
253269 }
254270
255271 //export goOnThemeChanged
256272 func goOnThemeChanged(id C.ulong, preferDark C.int) {
257- uid := uintptr(id)
258- regMu.RLock()
259- vw := viewByID[uid]
260- regMu.RUnlock()
261- if vw == nil {
262- return
263- }
264- // Inject runtime theme update into the page
265- dval := "false"
266- if int(preferDark) != 0 { dval = "true" }
267- js := fmt.Sprintf(`(() => { try { const d=%s; const cs=d?'dark':'light'; console.log('[dumber] theme change:'+cs);
273+ uid := uintptr(id)
274+ regMu.RLock()
275+ vw := viewByID[uid]
276+ regMu.RUnlock()
277+ if vw == nil {
278+ return
279+ }
280+ // Inject runtime theme update into the page
281+ dval := "false"
282+ if int(preferDark) != 0 {
283+ dval = "true"
284+ }
285+ js := fmt.Sprintf(`(() => { try { const d=%s; const cs=d?'dark':'light'; console.log('[dumber] theme change:'+cs);
268286 let m=document.querySelector('meta[name="color-scheme"]');
269287 if(!m){ m=document.createElement('meta'); m.name='color-scheme'; document.head.appendChild(m); }
270288 m.setAttribute('content', d ? 'dark light' : 'light dark');
@@ -272,57 +290,63 @@ let s=document.getElementById('__dumber_theme_style');
272290 if(!s){ s=document.createElement('style'); s.id='__dumber_theme_style'; document.documentElement.appendChild(s); }
273291 s.textContent=':root{color-scheme:' + (d?'dark':'light') + ';}';
274292 } catch(e) { console.warn('[dumber] theme runtime update failed', e); } })();`, dval)
275- _ = vw.InjectScript(js)
293+ _ = vw.InjectScript(js)
276294 }
277295
278296 //export goOnUcmMessage
279297 func goOnUcmMessage(id C.ulong, json *C.char) {
280- uid := uintptr(id)
281- regMu.RLock()
282- vw := viewByID[uid]
283- regMu.RUnlock()
284- if vw == nil || json == nil { return }
285- goPayload := C.GoString(json)
286- vw.dispatchScriptMessage(goPayload)
298+ uid := uintptr(id)
299+ regMu.RLock()
300+ vw := viewByID[uid]
301+ regMu.RUnlock()
302+ if vw == nil || json == nil {
303+ return
304+ }
305+ goPayload := C.GoString(json)
306+ vw.dispatchScriptMessage(goPayload)
287307 }
288308
289309 //export goOnTitleChanged
290310 func goOnTitleChanged(id C.ulong, ctitle *C.char) {
291- uid := uintptr(id)
292- regMu.RLock()
293- vw := viewByID[uid]
294- regMu.RUnlock()
295- if vw == nil || ctitle == nil { return }
296- title := C.GoString(ctitle)
297- vw.dispatchTitleChanged(title)
311+ uid := uintptr(id)
312+ regMu.RLock()
313+ vw := viewByID[uid]
314+ regMu.RUnlock()
315+ if vw == nil || ctitle == nil {
316+ return
317+ }
318+ title := C.GoString(ctitle)
319+ vw.dispatchTitleChanged(title)
298320 }
299321
300322 //export goOnURIChanged
301323 func goOnURIChanged(id C.ulong, curi *C.char) {
302- uid := uintptr(id)
303- regMu.RLock()
304- vw := viewByID[uid]
305- regMu.RUnlock()
306- if vw == nil || curi == nil { return }
307- uri := C.GoString(curi)
308- vw.dispatchURIChanged(uri)
324+ uid := uintptr(id)
325+ regMu.RLock()
326+ vw := viewByID[uid]
327+ regMu.RUnlock()
328+ if vw == nil || curi == nil {
329+ return
330+ }
331+ uri := C.GoString(curi)
332+ vw.dispatchURIChanged(uri)
309333 }
310334
311335 //export goOnButtonPress
312336 func goOnButtonPress(id C.ulong, button C.guint, state C.GdkModifierType) {
313- uid := uintptr(id)
314- btn := uint(button)
315- log.Printf("[mouse] button=%d state=0x%x", btn, uint(state))
316- regMu.RLock()
317- vw := viewByID[uid]
318- regMu.RUnlock()
319- if vw == nil {
320- return
321- }
322- switch btn {
323- case 8: // Back button on many mice
324- _ = vw.GoBack()
325- case 9: // Forward button on many mice
326- _ = vw.GoForward()
327- }
337+ uid := uintptr(id)
338+ btn := uint(button)
339+ log.Printf("[mouse] button=%d state=0x%x", btn, uint(state))
340+ regMu.RLock()
341+ vw := viewByID[uid]
342+ regMu.RUnlock()
343+ if vw == nil {
344+ return
345+ }
346+ switch btn {
347+ case 8: // Back button on many mice
348+ _ = vw.GoBack()
349+ case 9: // Forward button on many mice
350+ _ = vw.GoForward()
351+ }
328352 }
M pkg/webkit/loop.go +1 -2
@@ -6,9 +6,8 @@ import "log"
66
77 // RunMainLoop is a no-op in non-CGO builds.
88 func RunMainLoop() {
9- log.Printf("[webkit] RunMainLoop (non-CGO): no GUI loop available")
9+ log.Printf("[webkit] RunMainLoop (non-CGO): no GUI loop available")
1010 }
1111
1212 // QuitMainLoop is a no-op in non-CGO builds.
1313 func QuitMainLoop() {}
14-
M pkg/webkit/loop_cgo.go +7 -7
@@ -13,15 +13,15 @@ var mainLoop *C.GMainLoop
1313
1414 // RunMainLoop enters the GLib main loop (GTK4) and blocks until quit.
1515 func RunMainLoop() {
16- if mainLoop == nil {
17- mainLoop = C.g_main_loop_new(nil, C.gboolean(0))
18- }
19- C.g_main_loop_run(mainLoop)
16+ if mainLoop == nil {
17+ mainLoop = C.g_main_loop_new(nil, C.gboolean(0))
18+ }
19+ C.g_main_loop_run(mainLoop)
2020 }
2121
2222 // QuitMainLoop requests the main loop to exit.
2323 func QuitMainLoop() {
24- if mainLoop != nil {
25- C.g_main_loop_quit(mainLoop)
26- }
24+ if mainLoop != nil {
25+ C.g_main_loop_quit(mainLoop)
26+ }
2727 }
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
88 import "C"
99
1010 import (
11- "unsafe"
11+ "unsafe"
1212 )
1313
1414 // Resolver provides bytes and mime for a given custom URI.
@@ -21,30 +21,31 @@ func SetURISchemeResolver(r SchemeResolver) { globalResolver = r }
2121
2222 //export goResolveURIScheme
2323 func goResolveURIScheme(curi *C.char, outLen *C.size_t, outMime **C.char) unsafe.Pointer {
24- if curi == nil || outLen == nil || outMime == nil { return nil }
25- uri := C.GoString(curi)
26- if globalResolver == nil {
27- *outLen = 0
28- *outMime = nil
29- return nil
30- }
31- mime, data, ok := globalResolver(uri)
32- if !ok || len(data) == 0 {
33- *outLen = 0
34- *outMime = nil
35- return nil
36- }
37- buf := C.malloc(C.size_t(len(data)))
38- if buf == nil {
39- *outLen = 0
40- *outMime = nil
41- return nil
42- }
43- // Copy data into C buffer
44- dst := (*[1 << 30]byte)(buf)[:len(data):len(data)]
45- copy(dst, data)
46- *outLen = C.size_t(len(data))
47- *outMime = C.CString(mime)
48- return buf
24+ if curi == nil || outLen == nil || outMime == nil {
25+ return nil
26+ }
27+ uri := C.GoString(curi)
28+ if globalResolver == nil {
29+ *outLen = 0
30+ *outMime = nil
31+ return nil
32+ }
33+ mime, data, ok := globalResolver(uri)
34+ if !ok || len(data) == 0 {
35+ *outLen = 0
36+ *outMime = nil
37+ return nil
38+ }
39+ buf := C.malloc(C.size_t(len(data)))
40+ if buf == nil {
41+ *outLen = 0
42+ *outMime = nil
43+ return nil
44+ }
45+ // Copy data into C buffer
46+ dst := (*[1 << 30]byte)(buf)[:len(data):len(data)]
47+ copy(dst, data)
48+ *outLen = C.size_t(len(data))
49+ *outMime = C.CString(mime)
50+ return buf
4951 }
50-
M pkg/webkit/script.go +6 -6
@@ -4,10 +4,10 @@ package webkit
44
55 // InjectScript evaluates JavaScript in the active WebView context.
66 func (w *WebView) InjectScript(js string) error {
7- if w == nil || w.destroyed {
8- return ErrNotImplemented
9- }
10- // Store last script for potential debugging/testing.
11- _ = js
12- return nil
7+ if w == nil || w.destroyed {
8+ return ErrNotImplemented
9+ }
10+ // Store last script for potential debugging/testing.
11+ _ = js
12+ return nil
1313 }
M pkg/webkit/script_cgo.go +18 -18
@@ -12,22 +12,22 @@ import "C"
1212 import "unsafe"
1313
1414 func (w *WebView) InjectScript(js string) error {
15- if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
16- return ErrNotImplemented
17- }
18- cjs := C.CString(js)
19- defer C.free(unsafe.Pointer(cjs))
20- // Fire-and-forget evaluation using the modern API
21- // length: -1 (NUL-terminated), world_name/source_uri: NULL, starting_line_number: 0
22- C.webkit_web_view_evaluate_javascript(
23- w.native.wv,
24- (*C.gchar)(cjs),
25- C.gssize(-1),
26- nil, // world_name
27- nil, // source_uri
28- nil, // cancellable
29- nil, // callback
30- nil, // user_data
31- )
32- return nil
15+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
16+ return ErrNotImplemented
17+ }
18+ cjs := C.CString(js)
19+ defer C.free(unsafe.Pointer(cjs))
20+ // Fire-and-forget evaluation using the modern API
21+ // length: -1 (NUL-terminated), world_name/source_uri: NULL, starting_line_number: 0
22+ C.webkit_web_view_evaluate_javascript(
23+ w.native.wv,
24+ (*C.gchar)(cjs),
25+ C.gssize(-1),
26+ nil, // world_name
27+ nil, // source_uri
28+ nil, // cancellable
29+ nil, // callback
30+ nil, // user_data
31+ )
32+ return nil
3333 }
M pkg/webkit/settings.go +69 -21
@@ -3,37 +3,85 @@ package webkit
33 // Config holds initialization settings for the WebKit-based browser components.
44 // This will map to WebKit2GTK and GTK settings in the real implementation.
55 type Config struct {
6- // InitialURL is the first URL to load when creating a WebView.
7- InitialURL string
6+ // InitialURL is the first URL to load when creating a WebView.
7+ InitialURL string
88
9- // UserAgent allows overriding the default user agent string.
10- UserAgent string
9+ // UserAgent allows overriding the default user agent string.
10+ UserAgent string
1111
12- // EnableDeveloperExtras enables devtools/inspector.
13- EnableDeveloperExtras bool
12+ // EnableDeveloperExtras enables devtools/inspector.
13+ EnableDeveloperExtras bool
1414
15- // ZoomDefault sets an initial zoom factor (1.0 = 100%).
16- ZoomDefault float64
15+ // ZoomDefault sets an initial zoom factor (1.0 = 100%).
16+ ZoomDefault float64
1717
18- // DataDir is the base directory for persistent website data (cookies, localStorage, etc.).
19- DataDir string
20- // CacheDir is the base directory for cache data.
21- CacheDir string
18+ // DataDir is the base directory for persistent website data (cookies, localStorage, etc.).
19+ DataDir string
20+ // CacheDir is the base directory for cache data.
21+ CacheDir string
2222
23- // Fonts apply to pages that don't specify fonts (browser defaults).
24- DefaultSansFont string
25- DefaultSerifFont string
26- DefaultMonospaceFont string
27- DefaultFontSize int // CSS px (~points)
23+ // Fonts apply to pages that don't specify fonts (browser defaults).
24+ DefaultSansFont string
25+ DefaultSerifFont string
26+ DefaultMonospaceFont string
27+ DefaultFontSize int // CSS px (~points)
2828
29- // Rendering controls GPU/CPU selection and debug options
30- Rendering RenderingConfig
29+ // Rendering controls GPU/CPU selection and debug options
30+ Rendering RenderingConfig
31+
32+ // Memory controls memory optimization settings
33+ Memory MemoryConfig
3134 }
3235
3336 // RenderingConfig controls hardware acceleration preferences.
3437 // Mode accepts: "auto" (default), "gpu", or "cpu".
3538 // DebugGPU enables compositing indicators if supported.
3639 type RenderingConfig struct {
37- Mode string
38- DebugGPU bool
40+ Mode string
41+ DebugGPU bool
42+}
43+
44+// CacheModel represents WebKit cache model options for memory optimization.
45+type CacheModel string
46+
47+const (
48+ CacheModelDocumentViewer CacheModel = "document_viewer" // Minimal caching, lowest memory
49+ CacheModelWebBrowser CacheModel = "web_browser" // Default, balanced caching
50+ CacheModelPrimaryWebBrowser CacheModel = "primary_web_browser" // Maximum caching, highest memory
51+)
52+
53+// MemoryConfig controls memory optimization settings.
54+type MemoryConfig struct {
55+ // MemoryLimitMB sets the maximum memory limit per WebView in MB (0 = unlimited)
56+ MemoryLimitMB int
57+
58+ // ConservativeThreshold triggers early memory cleanup (0.0-1.0, default 0.33)
59+ ConservativeThreshold float64
60+
61+ // StrictThreshold triggers aggressive memory cleanup (0.0-1.0, default 0.5)
62+ StrictThreshold float64
63+
64+ // KillThreshold kills processes at this memory usage (0.0-1.0, 0 = disabled)
65+ KillThreshold float64
66+
67+ // PollIntervalSeconds sets how often to check memory usage (default 2.0)
68+ PollIntervalSeconds float64
69+
70+ // CacheModel determines caching strategy (default: web_browser)
71+ CacheModel CacheModel
72+
73+ // EnablePageCache enables back/forward page caching (default: true)
74+ EnablePageCache bool
75+
76+ // EnableOfflineAppCache enables offline application caching (default: true)
77+ EnableOfflineAppCache bool
78+
79+ // ProcessRecycleThreshold recycles WebView after N page loads (0 = disabled)
80+ ProcessRecycleThreshold int
81+
82+ // EnableGCInterval enables periodic JS garbage collection in seconds (0 = disabled)
83+ EnableGCInterval int
84+
85+ // EnableMemoryMonitoring logs memory usage and pressure events
86+ EnableMemoryMonitoring bool
3987 }
M pkg/webkit/webview.go +71 -53
@@ -3,81 +3,82 @@
33 package webkit
44
55 import (
6- "fmt"
7- "log"
6+ "fmt"
7+ "log"
88 )
99
1010 // WebView represents a browser view powered by WebKit2GTK.
1111 // Methods are currently stubs returning ErrNotImplemented to satisfy TDD ordering.
1212 type WebView struct {
13- config *Config
14- visible bool
15- zoom float64
16- url string
17- destroyed bool
18- window *Window
19- msgHandler func(payload string)
20- titleHandler func(title string)
21- uriHandler func(uri string)
13+ config *Config
14+ visible bool
15+ zoom float64
16+ url string
17+ destroyed bool
18+ window *Window
19+ msgHandler func(payload string)
20+ titleHandler func(title string)
21+ uriHandler func(uri string)
22+ zoomHandler func(level float64)
2223 }
2324
2425 // NewWebView constructs a new WebView instance.
2526 func NewWebView(cfg *Config) (*WebView, error) {
26- if cfg == nil {
27- cfg = &Config{}
28- }
29- log.Printf("[webkit] NewWebView (non-CGO stub) — UI will not be displayed. Build with -tags=webkit_cgo for native window.")
30- wv := &WebView{config: cfg}
31- if cfg.ZoomDefault <= 0 {
32- wv.zoom = 1.0
33- } else {
34- wv.zoom = cfg.ZoomDefault
35- }
36- // Construction succeeds; create a logical window placeholder.
37- wv.window = &Window{Title: "Dumber Browser"}
38- return wv, nil
27+ if cfg == nil {
28+ cfg = &Config{}
29+ }
30+ log.Printf("[webkit] NewWebView (non-CGO stub) — UI will not be displayed. Build with -tags=webkit_cgo for native window.")
31+ wv := &WebView{config: cfg}
32+ if cfg.ZoomDefault <= 0 {
33+ wv.zoom = 1.0
34+ } else {
35+ wv.zoom = cfg.ZoomDefault
36+ }
37+ // Construction succeeds; create a logical window placeholder.
38+ wv.window = &Window{Title: "Dumber Browser"}
39+ return wv, nil
3940 }
4041
4142 // LoadURL navigates the webview to the specified URL.
4243 func (w *WebView) LoadURL(rawURL string) error {
43- if w == nil || w.destroyed {
44- return ErrNotImplemented
45- }
46- if rawURL == "" {
47- return fmt.Errorf("url cannot be empty")
48- }
49- // Minimal validation; actual navigation handled by CGO bridge later.
50- w.url = rawURL
51- log.Printf("[webkit] LoadURL (non-CGO stub): %s", rawURL)
52- return nil
44+ if w == nil || w.destroyed {
45+ return ErrNotImplemented
46+ }
47+ if rawURL == "" {
48+ return fmt.Errorf("url cannot be empty")
49+ }
50+ // Minimal validation; actual navigation handled by CGO bridge later.
51+ w.url = rawURL
52+ log.Printf("[webkit] LoadURL (non-CGO stub): %s", rawURL)
53+ return nil
5354 }
5455
5556 // Show makes the WebView visible.
5657 func (w *WebView) Show() error {
57- if w == nil || w.destroyed {
58- return ErrNotImplemented
59- }
60- w.visible = true
61- log.Printf("[webkit] Show (non-CGO stub): no native window will appear")
62- return nil
58+ if w == nil || w.destroyed {
59+ return ErrNotImplemented
60+ }
61+ w.visible = true
62+ log.Printf("[webkit] Show (non-CGO stub): no native window will appear")
63+ return nil
6364 }
6465
6566 // Hide hides the WebView window.
6667 func (w *WebView) Hide() error {
67- if w == nil || w.destroyed {
68- return ErrNotImplemented
69- }
70- w.visible = false
71- return nil
68+ if w == nil || w.destroyed {
69+ return ErrNotImplemented
70+ }
71+ w.visible = false
72+ return nil
7273 }
7374
7475 // Destroy releases native resources.
7576 func (w *WebView) Destroy() error {
76- if w == nil {
77- return ErrNotImplemented
78- }
79- w.destroyed = true
80- return nil
77+ if w == nil {
78+ return ErrNotImplemented
79+ }
80+ w.destroyed = true
81+ return nil
8182 }
8283
8384 // Window returns the associated native window wrapper (non-nil).
@@ -88,11 +89,13 @@ func (w *WebView) GetCurrentURL() string { return w.url }
8889
8990 // GoBack is not supported in the non-CGO stub.
9091 func (w *WebView) GoBack() error { return ErrNotImplemented }
92+
9193 // GoForward is not supported in the non-CGO stub.
9294 func (w *WebView) GoForward() error { return ErrNotImplemented }
9395
9496 // ShowDevTools is a no-op in the non-CGO build.
9597 func (w *WebView) ShowDevTools() error { return nil }
98+
9699 // CloseDevTools is a no-op in the non-CGO build.
97100 func (w *WebView) CloseDevTools() error { return nil }
98101
@@ -100,19 +103,34 @@ func (w *WebView) CloseDevTools() error { return nil }
100103 func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }
101104
102105 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+ }
104109 }
105110
106111 // RegisterTitleChangedHandler registers a callback invoked when the page title changes.
107112 func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }
108113
109114 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+ }
111118 }
112119
113120 // RegisterURIChangedHandler registers a callback invoked when the current page URI changes.
114121 func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb }
115122
116123 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+ }
118136 }
M pkg/webkit/webview_cgo.go +649 -303
@@ -23,7 +23,7 @@ static WebKitWebView* as_webview(GtkWidget* w) { return WEBKIT_WEB_VIEW(w); }
2323 static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy);
2424
2525 // 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) {
2727 WebKitUserContentManager* u = webkit_user_content_manager_new();
2828 if (!u) return NULL;
2929 // 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
3131 data_dir ? data_dir : "",
3232 cache_dir ? cache_dir : "");
3333 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+ }
3439 // Configure cookie persistence and policy (no third-party by default)
3540 WebKitCookieManager* cm = webkit_network_session_get_cookie_manager(sess);
3641 if (cm) {
@@ -231,18 +236,106 @@ static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy) {
231236 (void)policy; // Not supported in this header version
232237 #endif
233238 }
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+}
234318 */
235319 import "C"
236320
237321 import (
238- "errors"
239- "log"
240- "os"
241- "path/filepath"
242- "runtime"
243- "unsafe"
322+ "errors"
323+ "log"
324+ "os"
325+ "path/filepath"
326+ "runtime"
327+ "time"
328+ "unsafe"
244329 )
245330
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+
246339 const omniboxDefaultScript = `(() => {
247340 try {
248341 if (window.__dumber_omnibox_loaded) return; window.__dumber_omnibox_loaded = true;
@@ -274,261 +367,408 @@ const omniboxDefaultScript = `(() => {
274367 })();`
275368
276369 type nativeView struct {
277- win *C.GtkWidget
278- view *C.GtkWidget
279- wv *C.WebKitWebView
280- ucm *C.WebKitUserContentManager
370+ win *C.GtkWidget
371+ view *C.GtkWidget
372+ wv *C.WebKitWebView
373+ ucm *C.WebKitUserContentManager
374+}
375+
376+type memoryStats struct {
377+ pageLoadCount int
378+ lastGCTime time.Time
379+ memoryPressureSettings *C.WebKitMemoryPressureSettings
281380 }
282381
283382 // WebView represents a browser view powered by WebKit2GTK.
284383 type WebView struct {
285- config *Config
286- zoom float64
287- url string
288- destroyed bool
289- native *nativeView
290- window *Window
291- id uintptr
292- msgHandler func(payload string)
293- titleHandler func(title string)
294- uriHandler func(uri string)
295- zoomHandler func(level float64)
384+ config *Config
385+ zoom float64
386+ url string
387+ destroyed bool
388+ native *nativeView
389+ window *Window
390+ id uintptr
391+ msgHandler func(payload string)
392+ titleHandler func(title string)
393+ uriHandler func(uri string)
394+ zoomHandler func(level float64)
395+ memStats *memoryStats
396+ gcTicker *time.Ticker
397+ gcDone chan struct{}
296398 }
297399
298400 // NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
299401 func NewWebView(cfg *Config) (*WebView, error) {
300- log.Printf("[webkit] Initializing GTK and creating WebView (CGO)")
301- if C.gtk_init_check() == 0 {
302- return nil, errors.New("failed to initialize GTK")
303- }
304-
305- if cfg == nil {
306- cfg = &Config{}
307- }
308-
309- // Prepare persistent website data/caches
310- dataDir := cfg.DataDir
311- cacheDir := cfg.CacheDir
312- if dataDir == "" { dataDir = filepath.Join(os.TempDir(), "dumber-webkit-data") }
313- if cacheDir == "" { cacheDir = filepath.Join(os.TempDir(), "dumber-webkit-cache") }
314- _ = os.MkdirAll(dataDir, 0o755)
315- _ = os.MkdirAll(cacheDir, 0o755)
316-
317- cData := C.CString(dataDir)
318- cCache := C.CString(cacheDir)
319- cookieFile := filepath.Join(dataDir, "cookies.db")
320- cCookie := C.CString(cookieFile)
321- defer C.free(unsafe.Pointer(cData))
322- defer C.free(unsafe.Pointer(cCache))
323- defer C.free(unsafe.Pointer(cCookie))
324- // Use default WebContext to register the custom URI scheme handler; the
325- // WebView itself is created with a WebKitNetworkSession for persistence.
326- ctx := C.webkit_web_context_get_default()
327-
328- // Register custom URI scheme handler for "dumb://"
329- {
330- sch := C.CString("dumb")
331- C.webkit_web_context_register_uri_scheme(ctx, sch, (C.WebKitURISchemeRequestCallback)(C.on_uri_scheme), nil, nil)
332- C.free(unsafe.Pointer(sch))
333- }
334- // Cookie manager persistent storage handled via NetworkSession in new_webview_with_ucm_and_session
335-
336- // Create WebView through g_object_new with a fresh UCM (GTK4/WebKit 6 style)
337- var createdUcm *C.WebKitUserContentManager
338- viewWidget := C.new_webview_with_ucm_and_session(cData, cCache, cCookie, &createdUcm)
339- if viewWidget == nil {
340- return nil, errors.New("failed to create WebKitWebView")
341- }
342-
343- // Create a top-level window to host the view
344- win := C.new_window()
345- if win == nil {
346- return nil, errors.New("failed to create GtkWindow")
347- }
348-
349- // Pack view widget into the window
350- // GTK4: containers removed; use gtk_window_set_child
351- C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), viewWidget)
352- C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768)
353- C.connect_destroy_quit(win)
354-
355- v := &WebView{
356- config: cfg,
357- zoom: 1.0,
358- native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm},
359- window: &Window{win: win},
360- }
361- // Assign an ID for accelerator dispatch
362- v.id = nextViewID()
363- registerView(v.id, v)
364- // Attach GTK4 input controllers (keyboard, mouse)
365- AttachKeyboardControllers(v)
366-
367- // Watch for GTK theme changes and propagate to page at runtime
368- if settings := C.gtk_settings_get_default(); settings != nil {
369- cprop := C.CString("notify::gtk-application-prefer-dark-theme")
370- C.g_signal_connect_data(C.gpointer(unsafe.Pointer(settings)), cprop, C.GCallback(C.on_theme_changed), C.gpointer(v.id), nil, 0)
371- C.free(unsafe.Pointer(cprop))
372- }
373-
374- // Native zoom shortcuts (independent of app services)
375- // Ctrl+='=' and Ctrl+'+' → Zoom In; Ctrl+'-' → Zoom Out; Ctrl+'0' → Reset
376- _ = v.RegisterKeyboardShortcut("cmdorctrl+=", func() {
377- nz := v.zoom
378- if nz <= 0 { nz = 1.0 }
379- nz *= 1.1
380- if nz < 0.25 { nz = 0.25 }
381- if nz > 5.0 { nz = 5.0 }
382- _ = v.SetZoom(nz)
383- })
384- _ = v.RegisterKeyboardShortcut("cmdorctrl+plus", func() {
385- nz := v.zoom
386- if nz <= 0 { nz = 1.0 }
387- nz *= 1.1
388- if nz < 0.25 { nz = 0.25 }
389- if nz > 5.0 { nz = 5.0 }
390- _ = v.SetZoom(nz)
391- })
392- _ = v.RegisterKeyboardShortcut("cmdorctrl-", func() {
393- nz := v.zoom
394- if nz <= 0 { nz = 1.0 }
395- nz /= 1.1
396- if nz < 0.25 { nz = 0.25 }
397- if nz > 5.0 { nz = 5.0 }
398- _ = v.SetZoom(nz)
399- })
400- _ = v.RegisterKeyboardShortcut("cmdorctrl+0", func() { _ = v.SetZoom(1.0) })
401- // Navigation with Alt+Arrow keys
402- _ = v.RegisterKeyboardShortcut("alt+ArrowLeft", func() { _ = v.GoBack() })
403- _ = v.RegisterKeyboardShortcut("alt+ArrowRight", func() { _ = v.GoForward() })
404- // Update window title when page title changes
405- cNotifyTitle1 := C.CString("notify::title")
406- C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyTitle1, C.GCallback(C.on_title_notify), C.gpointer(unsafe.Pointer(win)), nil, 0)
407- C.free(unsafe.Pointer(cNotifyTitle1))
408- // Also dispatch title change to Go with view id
409- cNotifyTitle2 := C.CString("notify::title")
410- C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyTitle2, C.GCallback(C.on_title_notify_id), C.gpointer(v.id), nil, 0)
411- C.free(unsafe.Pointer(cNotifyTitle2))
412- // Notify URI changes to Go to keep current URL in sync
413- cNotifyURI := C.CString("notify::uri")
414- C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyURI, C.GCallback(C.on_uri_notify), C.gpointer(v.id), nil, 0)
415- C.free(unsafe.Pointer(cNotifyURI))
416- // Apply hardware acceleration and related settings based on cfg.Rendering
417- if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
418- // Hardware acceleration policy (guarded by version)
419- switch cfg.Rendering.Mode {
420- case "gpu":
421- C.maybe_set_hw_policy(settings, 1)
422- C.maybe_set_webgl(settings, 1)
423- C.maybe_set_canvas_accel(settings, 1)
424- case "cpu":
425- C.maybe_set_hw_policy(settings, 2)
426- C.maybe_set_webgl(settings, 0)
427- C.maybe_set_canvas_accel(settings, 0)
428- default: // auto
429- C.maybe_set_hw_policy(settings, 0)
430- C.maybe_set_webgl(settings, 1)
431- C.maybe_set_canvas_accel(settings, 1)
432- }
433- // Optional compositing indicators for debugging (guarded by version)
434- if cfg.Rendering.DebugGPU { C.maybe_set_draw_indicators(settings, 1) }
435- // Reduce media pipeline churn by requiring a user gesture for playback
436- C.maybe_set_media_user_gesture(settings, 1)
437- // Enable trackpad back/forward gestures when available
438- C.maybe_set_back_forward_gestures(settings, 1)
439- }
440-
441- if cfg.ZoomDefault > 0 {
442- v.zoom = cfg.ZoomDefault
443- C.webkit_web_view_set_zoom_level(v.native.wv, C.gdouble(v.zoom))
444- }
445- if cfg.EnableDeveloperExtras {
446- settings := C.webkit_web_view_get_settings(v.native.wv)
447- if settings != nil {
448- C.webkit_settings_set_enable_developer_extras(settings, C.gboolean(1))
449- }
450- }
451- // Apply default fonts if provided in cfg
452- if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
453- if cfg.DefaultSansFont != "" {
454- csans := C.CString(cfg.DefaultSansFont)
455- C.webkit_settings_set_sans_serif_font_family(settings, (*C.gchar)(csans))
456- C.free(unsafe.Pointer(csans))
457- // Also set default font family to sans
458- csans2 := C.CString(cfg.DefaultSansFont)
459- C.webkit_settings_set_default_font_family(settings, (*C.gchar)(csans2))
460- C.free(unsafe.Pointer(csans2))
461- }
462- if cfg.DefaultSerifFont != "" {
463- cserif := C.CString(cfg.DefaultSerifFont)
464- C.webkit_settings_set_serif_font_family(settings, (*C.gchar)(cserif))
465- C.free(unsafe.Pointer(cserif))
466- }
467- if cfg.DefaultMonospaceFont != "" {
468- cmono := C.CString(cfg.DefaultMonospaceFont)
469- C.webkit_settings_set_monospace_font_family(settings, (*C.gchar)(cmono))
470- C.free(unsafe.Pointer(cmono))
471- }
472- if cfg.DefaultFontSize > 0 {
473- C.webkit_settings_set_default_font_size(settings, C.guint(cfg.DefaultFontSize))
474- }
475- }
476- if cfg.InitialURL != "" {
477- _ = v.LoadURL(cfg.InitialURL)
478- }
479- // Ensure native resources are released if user forgets to call Destroy
480- runtime.SetFinalizer(v, func(v *WebView) { _ = v.Destroy() })
481- log.Printf("[webkit] WebView created (CGO)")
482-
483- // Initialize UCM scripts and handlers
484- if v.native != nil && v.native.ucm != nil {
485- v.enableUserContentManager()
486- }
487- return v, nil
402+ log.Printf("[webkit] Initializing GTK and creating WebView (CGO)")
403+ if C.gtk_init_check() == 0 {
404+ return nil, errors.New("failed to initialize GTK")
405+ }
406+
407+ if cfg == nil {
408+ cfg = &Config{}
409+ }
410+
411+ // Prepare persistent website data/caches
412+ dataDir := cfg.DataDir
413+ cacheDir := cfg.CacheDir
414+ if dataDir == "" {
415+ dataDir = filepath.Join(os.TempDir(), "dumber-webkit-data")
416+ }
417+ if cacheDir == "" {
418+ cacheDir = filepath.Join(os.TempDir(), "dumber-webkit-cache")
419+ }
420+ _ = os.MkdirAll(dataDir, 0o755)
421+ _ = os.MkdirAll(cacheDir, 0o755)
422+
423+ cData := C.CString(dataDir)
424+ cCache := C.CString(cacheDir)
425+ cookieFile := filepath.Join(dataDir, "cookies.db")
426+ cCookie := C.CString(cookieFile)
427+ defer C.free(unsafe.Pointer(cData))
428+ defer C.free(unsafe.Pointer(cCache))
429+ defer C.free(unsafe.Pointer(cCookie))
430+ // Use default WebContext to register the custom URI scheme handler; the
431+ // WebView itself is created with a WebKitNetworkSession for persistence.
432+ ctx := C.webkit_web_context_get_default()
433+
434+ // Apply cache model to WebContext
435+ // Default to WEBKIT_CACHE_MODEL_WEB_BROWSER to keep caching enabled
436+ cacheModel := 1
437+ if cfg.Memory.CacheModel != "" {
438+ switch cfg.Memory.CacheModel {
439+ case CacheModelWebBrowser:
440+ cacheModel = 1
441+ case CacheModelPrimaryWebBrowser:
442+ cacheModel = 2
443+ default: // CacheModelDocumentViewer or unset
444+ cacheModel = 0
445+ }
446+ }
447+ C.maybe_set_cache_model(ctx, C.int(cacheModel))
448+
449+ // Configure memory pressure settings if specified
450+ var pressureSettings *C.WebKitMemoryPressureSettings
451+ // Only configure memory pressure if explicitly requested via config
452+ if cfg.Memory.MemoryLimitMB > 0 || cfg.Memory.ConservativeThreshold > 0 || cfg.Memory.StrictThreshold > 0 || cfg.Memory.KillThreshold > 0 || cfg.Memory.PollIntervalSeconds > 0 {
453+ memLimitMB := cfg.Memory.MemoryLimitMB
454+ conservativeThreshold := cfg.Memory.ConservativeThreshold
455+ strictThreshold := cfg.Memory.StrictThreshold
456+ killThreshold := cfg.Memory.KillThreshold
457+ pollInterval := cfg.Memory.PollIntervalSeconds
458+
459+ pressureSettings = C.create_memory_pressure_settings(
460+ C.uint(memLimitMB),
461+ C.double(conservativeThreshold),
462+ C.double(strictThreshold),
463+ C.double(killThreshold),
464+ C.double(pollInterval))
465+
466+ if pressureSettings != nil {
467+ C.maybe_set_memory_pressure_settings(ctx, pressureSettings)
468+ if cfg.Memory.EnableMemoryMonitoring {
469+ log.Printf("[webkit] Memory pressure settings applied: limit=%dMB, conservative=%.2f, strict=%.2f, poll=%.1fs",
470+ memLimitMB, conservativeThreshold, strictThreshold, pollInterval)
471+ }
472+ }
473+ }
474+
475+ // Register custom URI scheme handler for "dumb://"
476+ {
477+ sch := C.CString("dumb")
478+ C.webkit_web_context_register_uri_scheme(ctx, sch, (C.WebKitURISchemeRequestCallback)(C.on_uri_scheme), nil, nil)
479+ C.free(unsafe.Pointer(sch))
480+ }
481+ // Cookie manager persistent storage handled via NetworkSession in new_webview_with_ucm_and_session
482+
483+ // Create WebView through g_object_new with a fresh UCM (GTK4/WebKit 6 style)
484+ var createdUcm *C.WebKitUserContentManager
485+ viewWidget := C.new_webview_with_ucm_and_session(cData, cCache, cCookie, &createdUcm, pressureSettings)
486+ if viewWidget == nil {
487+ return nil, errors.New("failed to create WebKitWebView")
488+ }
489+
490+ // Create a top-level window to host the view
491+ win := C.new_window()
492+ if win == nil {
493+ return nil, errors.New("failed to create GtkWindow")
494+ }
495+
496+ // Pack view widget into the window
497+ // GTK4: containers removed; use gtk_window_set_child
498+ C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), viewWidget)
499+ C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768)
500+ C.connect_destroy_quit(win)
501+
502+ v := &WebView{
503+ config: cfg,
504+ zoom: 1.0,
505+ native: &nativeView{win: win, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm},
506+ window: &Window{win: win},
507+ memStats: &memoryStats{
508+ pageLoadCount: 0,
509+ lastGCTime: time.Now(),
510+ memoryPressureSettings: pressureSettings,
511+ },
512+ gcDone: make(chan struct{}),
513+ }
514+ // Assign an ID for accelerator dispatch
515+ v.id = nextViewID()
516+ registerView(v.id, v)
517+
518+ // Register with global memory manager if monitoring is enabled
519+ if cfg.Memory.EnableMemoryMonitoring {
520+ if globalMemoryManager == nil {
521+ InitializeGlobalMemoryManager(
522+ cfg.Memory.EnableMemoryMonitoring,
523+ cfg.Memory.ProcessRecycleThreshold,
524+ 30*time.Second, // Default monitoring interval
525+ )
526+ }
527+ if globalMemoryManager != nil {
528+ globalMemoryManager.RegisterWebView(v)
529+ }
530+ }
531+ // Attach GTK4 input controllers (keyboard, mouse)
532+ AttachKeyboardControllers(v)
533+
534+ // Watch for GTK theme changes and propagate to page at runtime
535+ if settings := C.gtk_settings_get_default(); settings != nil {
536+ cprop := C.CString("notify::gtk-application-prefer-dark-theme")
537+ C.g_signal_connect_data(C.gpointer(unsafe.Pointer(settings)), cprop, C.GCallback(C.on_theme_changed), C.gpointer(v.id), nil, 0)
538+ C.free(unsafe.Pointer(cprop))
539+ }
540+
541+ // Native zoom shortcuts (independent of app services)
542+ // Ctrl+='=' and Ctrl+'+' → Zoom In; Ctrl+'-' → Zoom Out; Ctrl+'0' → Reset
543+ _ = v.RegisterKeyboardShortcut("cmdorctrl+=", func() {
544+ nz := v.zoom
545+ if nz <= 0 {
546+ nz = 1.0
547+ }
548+ nz *= 1.1
549+ if nz < 0.25 {
550+ nz = 0.25
551+ }
552+ if nz > 5.0 {
553+ nz = 5.0
554+ }
555+ _ = v.SetZoom(nz)
556+ })
557+ _ = v.RegisterKeyboardShortcut("cmdorctrl+plus", func() {
558+ nz := v.zoom
559+ if nz <= 0 {
560+ nz = 1.0
561+ }
562+ nz *= 1.1
563+ if nz < 0.25 {
564+ nz = 0.25
565+ }
566+ if nz > 5.0 {
567+ nz = 5.0
568+ }
569+ _ = v.SetZoom(nz)
570+ })
571+ _ = v.RegisterKeyboardShortcut("cmdorctrl-", func() {
572+ nz := v.zoom
573+ if nz <= 0 {
574+ nz = 1.0
575+ }
576+ nz /= 1.1
577+ if nz < 0.25 {
578+ nz = 0.25
579+ }
580+ if nz > 5.0 {
581+ nz = 5.0
582+ }
583+ _ = v.SetZoom(nz)
584+ })
585+ _ = v.RegisterKeyboardShortcut("cmdorctrl+0", func() { _ = v.SetZoom(1.0) })
586+ // Navigation with Alt+Arrow keys
587+ _ = v.RegisterKeyboardShortcut("alt+ArrowLeft", func() { _ = v.GoBack() })
588+ _ = v.RegisterKeyboardShortcut("alt+ArrowRight", func() { _ = v.GoForward() })
589+ // Update window title when page title changes
590+ cNotifyTitle1 := C.CString("notify::title")
591+ C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyTitle1, C.GCallback(C.on_title_notify), C.gpointer(unsafe.Pointer(win)), nil, 0)
592+ C.free(unsafe.Pointer(cNotifyTitle1))
593+ // Also dispatch title change to Go with view id
594+ cNotifyTitle2 := C.CString("notify::title")
595+ C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyTitle2, C.GCallback(C.on_title_notify_id), C.gpointer(v.id), nil, 0)
596+ C.free(unsafe.Pointer(cNotifyTitle2))
597+ // Notify URI changes to Go to keep current URL in sync
598+ cNotifyURI := C.CString("notify::uri")
599+ C.g_signal_connect_data(C.gpointer(unsafe.Pointer(viewWidget)), cNotifyURI, C.GCallback(C.on_uri_notify), C.gpointer(v.id), nil, 0)
600+ C.free(unsafe.Pointer(cNotifyURI))
601+ // Apply hardware acceleration and related settings based on cfg.Rendering
602+ if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
603+ // Hardware acceleration policy (guarded by version)
604+ switch cfg.Rendering.Mode {
605+ case "gpu":
606+ C.maybe_set_hw_policy(settings, 1)
607+ C.maybe_set_webgl(settings, 1)
608+ C.maybe_set_canvas_accel(settings, 1)
609+ case "cpu":
610+ C.maybe_set_hw_policy(settings, 2)
611+ C.maybe_set_webgl(settings, 0)
612+ C.maybe_set_canvas_accel(settings, 0)
613+ default: // auto
614+ C.maybe_set_hw_policy(settings, 0)
615+ C.maybe_set_webgl(settings, 1)
616+ C.maybe_set_canvas_accel(settings, 1)
617+ }
618+ // Optional compositing indicators for debugging (guarded by version)
619+ if cfg.Rendering.DebugGPU {
620+ C.maybe_set_draw_indicators(settings, 1)
621+ }
622+ // Reduce media pipeline churn by requiring a user gesture for playback
623+ C.maybe_set_media_user_gesture(settings, 1)
624+ // Enable trackpad back/forward gestures when available
625+ C.maybe_set_back_forward_gestures(settings, 1)
626+
627+ // Keep page cache enabled by default for performance
628+ enablePageCache := true
629+ if cfg.Memory.EnablePageCache {
630+ enablePageCache = true
631+ }
632+ C.maybe_set_page_cache(settings, C.int(boolToInt(enablePageCache)))
633+
634+ // Offline app cache is deprecated; keep as no-op
635+ C.maybe_set_offline_app_cache(settings, C.int(0))
636+
637+ if cfg.Memory.EnableMemoryMonitoring {
638+ log.Printf("[webkit] Memory settings applied: page_cache=%v", enablePageCache)
639+ }
640+ }
641+
642+ if cfg.ZoomDefault > 0 {
643+ v.zoom = cfg.ZoomDefault
644+ C.webkit_web_view_set_zoom_level(v.native.wv, C.gdouble(v.zoom))
645+ }
646+ if cfg.EnableDeveloperExtras {
647+ settings := C.webkit_web_view_get_settings(v.native.wv)
648+ if settings != nil {
649+ C.webkit_settings_set_enable_developer_extras(settings, C.gboolean(1))
650+ }
651+ }
652+ // Apply default fonts if provided in cfg
653+ if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
654+ if cfg.DefaultSansFont != "" {
655+ csans := C.CString(cfg.DefaultSansFont)
656+ C.webkit_settings_set_sans_serif_font_family(settings, (*C.gchar)(csans))
657+ C.free(unsafe.Pointer(csans))
658+ // Also set default font family to sans
659+ csans2 := C.CString(cfg.DefaultSansFont)
660+ C.webkit_settings_set_default_font_family(settings, (*C.gchar)(csans2))
661+ C.free(unsafe.Pointer(csans2))
662+ }
663+ if cfg.DefaultSerifFont != "" {
664+ cserif := C.CString(cfg.DefaultSerifFont)
665+ C.webkit_settings_set_serif_font_family(settings, (*C.gchar)(cserif))
666+ C.free(unsafe.Pointer(cserif))
667+ }
668+ if cfg.DefaultMonospaceFont != "" {
669+ cmono := C.CString(cfg.DefaultMonospaceFont)
670+ C.webkit_settings_set_monospace_font_family(settings, (*C.gchar)(cmono))
671+ C.free(unsafe.Pointer(cmono))
672+ }
673+ if cfg.DefaultFontSize > 0 {
674+ C.webkit_settings_set_default_font_size(settings, C.guint(cfg.DefaultFontSize))
675+ }
676+ }
677+ if cfg.InitialURL != "" {
678+ _ = v.LoadURL(cfg.InitialURL)
679+ }
680+ // Ensure native resources are released if user forgets to call Destroy
681+ runtime.SetFinalizer(v, func(v *WebView) { _ = v.Destroy() })
682+ log.Printf("[webkit] WebView created (CGO)")
683+
684+ // Initialize UCM scripts and handlers
685+ if v.native != nil && v.native.ucm != nil {
686+ v.enableUserContentManager()
687+ }
688+
689+ // Start periodic JavaScript garbage collection if configured
690+ if cfg.Memory.EnableGCInterval > 0 {
691+ v.startPeriodicGC(time.Duration(cfg.Memory.EnableGCInterval) * time.Second)
692+ if cfg.Memory.EnableMemoryMonitoring {
693+ log.Printf("[webkit] Periodic GC enabled: interval=%ds", cfg.Memory.EnableGCInterval)
694+ }
695+ }
696+
697+ return v, nil
488698 }
489699
490700 func (w *WebView) LoadURL(rawURL string) error {
491- if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
492- return ErrNotImplemented
493- }
494- w.url = rawURL
495- curl := C.CString(rawURL)
496- defer C.free(unsafe.Pointer(curl))
497- C.webkit_web_view_load_uri(w.native.wv, (*C.gchar)(curl))
498- log.Printf("[webkit] LoadURL: %s", rawURL)
499- return nil
701+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
702+ return ErrNotImplemented
703+ }
704+
705+ // Track page loads for process recycling
706+ if w.memStats != nil {
707+ w.memStats.pageLoadCount++
708+
709+ // Check if we need to recycle this WebView
710+ if w.config.Memory.ProcessRecycleThreshold > 0 &&
711+ w.memStats.pageLoadCount >= w.config.Memory.ProcessRecycleThreshold {
712+ if w.config.Memory.EnableMemoryMonitoring {
713+ log.Printf("[webkit] Process recycle threshold reached (%d pages), consider creating new WebView",
714+ w.memStats.pageLoadCount)
715+ }
716+ }
717+ }
718+
719+ w.url = rawURL
720+ curl := C.CString(rawURL)
721+ defer C.free(unsafe.Pointer(curl))
722+ C.webkit_web_view_load_uri(w.native.wv, (*C.gchar)(curl))
723+ log.Printf("[webkit] LoadURL: %s", rawURL)
724+ return nil
500725 }
501726
502727 func (w *WebView) Show() error {
503- if w == nil || w.destroyed || w.native == nil || w.native.win == nil {
504- return ErrNotImplemented
505- }
506- C.gtk_widget_set_visible(w.native.view, C.gboolean(1))
507- C.gtk_widget_set_visible(w.native.win, C.gboolean(1))
508- C.gtk_window_present((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
509- log.Printf("[webkit] Show window")
510- return nil
728+ if w == nil || w.destroyed || w.native == nil || w.native.win == nil {
729+ return ErrNotImplemented
730+ }
731+ C.gtk_widget_set_visible(w.native.view, C.gboolean(1))
732+ C.gtk_widget_set_visible(w.native.win, C.gboolean(1))
733+ C.gtk_window_present((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
734+ log.Printf("[webkit] Show window")
735+ return nil
511736 }
512737
513738 func (w *WebView) Hide() error {
514- if w == nil || w.destroyed || w.native == nil || w.native.win == nil {
515- return ErrNotImplemented
516- }
517- C.gtk_widget_set_visible(w.native.view, C.gboolean(0))
518- C.gtk_widget_set_visible(w.native.win, C.gboolean(0))
519- return nil
739+ if w == nil || w.destroyed || w.native == nil || w.native.win == nil {
740+ return ErrNotImplemented
741+ }
742+ C.gtk_widget_set_visible(w.native.view, C.gboolean(0))
743+ C.gtk_widget_set_visible(w.native.win, C.gboolean(0))
744+ return nil
520745 }
521746
522747 func (w *WebView) Destroy() error {
523- if w == nil || w.native == nil || w.native.win == nil {
524- return ErrNotImplemented
525- }
526- // GTK4: destroy windows via gtk_window_destroy
527- C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
528- w.destroyed = true
529- unregisterView(w.id)
530- log.Printf("[webkit] Destroy window")
531- return nil
748+ if w == nil || w.native == nil || w.native.win == nil {
749+ return ErrNotImplemented
750+ }
751+
752+ // Stop periodic GC if running
753+ w.stopPeriodicGC()
754+
755+ // Clean up memory pressure settings
756+ if w.memStats != nil && w.memStats.memoryPressureSettings != nil {
757+ // WebKit will clean up the memory pressure settings when context is destroyed
758+ w.memStats.memoryPressureSettings = nil
759+ }
760+
761+ // Unregister from memory manager
762+ if globalMemoryManager != nil {
763+ globalMemoryManager.UnregisterWebView(w.id)
764+ }
765+
766+ // GTK4: destroy windows via gtk_window_destroy
767+ C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
768+ w.destroyed = true
769+ unregisterView(w.id)
770+ log.Printf("[webkit] Destroy window")
771+ return nil
532772 }
533773
534774 // Window returns the associated native window.
@@ -536,103 +776,209 @@ func (w *WebView) Window() *Window { return w.window }
536776
537777 // GetCurrentURL returns the current URI from WebKit.
538778 func (w *WebView) GetCurrentURL() string {
539- if w == nil || w.native == nil || w.native.wv == nil { return "" }
540- uri := C.webkit_web_view_get_uri(w.native.wv)
541- if uri == nil { return "" }
542- return C.GoString((*C.char)(unsafe.Pointer(uri)))
779+ if w == nil || w.native == nil || w.native.wv == nil {
780+ return ""
781+ }
782+ uri := C.webkit_web_view_get_uri(w.native.wv)
783+ if uri == nil {
784+ return ""
785+ }
786+ return C.GoString((*C.char)(unsafe.Pointer(uri)))
543787 }
544788
545789 func (w *WebView) GoBack() error {
546- if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented }
547- C.webkit_web_view_go_back(w.native.wv)
548- return nil
790+ if w == nil || w.native == nil || w.native.wv == nil {
791+ return ErrNotImplemented
792+ }
793+ C.webkit_web_view_go_back(w.native.wv)
794+ return nil
549795 }
550796
551797 func (w *WebView) GoForward() error {
552- if w == nil || w.native == nil || w.native.wv == nil { return ErrNotImplemented }
553- C.webkit_web_view_go_forward(w.native.wv)
554- return nil
798+ if w == nil || w.native == nil || w.native.wv == nil {
799+ return ErrNotImplemented
800+ }
801+ C.webkit_web_view_go_forward(w.native.wv)
802+ return nil
555803 }
556804
557805 // RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.
558806 func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }
559807
560808 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+ }
562812 }
563813
564814 // RegisterTitleChangedHandler registers a callback invoked when the page title changes.
565815 func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }
566816
567817 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+ }
569821 }
570822
571823 // RegisterURIChangedHandler registers a callback invoked when the current page URI changes.
572824 func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb }
573825
574826 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+ }
576830 }
577831
578832 // RegisterZoomChangedHandler registers a callback invoked when zoom level changes.
579833 func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb }
580834
581835 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+ }
583839 }
584840
585841 // enableUserContentManager registers the 'dumber' message handler and injects the omnibox script.
586842 func (w *WebView) enableUserContentManager() {
587- if w == nil || w.native == nil || w.native.ucm == nil { return }
588- // Register handler "dumber"
589- cname := C.CString("dumber")
590- ok := C.register_ucm_handler(w.native.ucm, cname, C.ulong(w.id))
591- if ok == 0 {
592- log.Printf("[webkit] Failed to register UCM handler; fallback bridge will handle messages")
593- }
594- C.free(unsafe.Pointer(cname))
843+ if w == nil || w.native == nil || w.native.ucm == nil {
844+ return
845+ }
846+ // Register handler "dumber"
847+ cname := C.CString("dumber")
848+ ok := C.register_ucm_handler(w.native.ucm, cname, C.ulong(w.id))
849+ if ok == 0 {
850+ log.Printf("[webkit] Failed to register UCM handler; fallback bridge will handle messages")
851+ }
852+ C.free(unsafe.Pointer(cname))
853+
854+ // Inject color-scheme preference script at document-start to inform sites of system theme
855+ preferDark := C.gtk_prefers_dark() != 0
856+ if preferDark {
857+ log.Printf("[theme] GTK prefers: dark")
858+ } else {
859+ log.Printf("[theme] GTK prefers: light")
860+ }
861+ var schemeJS string
862+ if preferDark {
863+ schemeJS = "(() => { try { const d=true; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:' + cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='dark light'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:dark;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();"
864+ } else {
865+ schemeJS = "(() => { try { const d=false; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:' + cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='light dark'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:light;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();"
866+ }
867+ cScheme := C.CString(schemeJS)
868+ defer C.free(unsafe.Pointer(cScheme))
869+ schemeScript := C.webkit_user_script_new((*C.gchar)(cScheme), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
870+ if schemeScript != nil {
871+ C.webkit_user_content_manager_add_script(w.native.ucm, schemeScript)
872+ C.webkit_user_script_unref(schemeScript)
873+ }
874+
875+ // Add user script at document-start
876+ src := C.CString(omniboxDefaultScript)
877+ defer C.free(unsafe.Pointer(src))
878+ script := C.webkit_user_script_new((*C.gchar)(src), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
879+ if script != nil {
880+ C.webkit_user_content_manager_add_script(w.native.ucm, script)
881+ C.webkit_user_script_unref(script)
882+ log.Printf("[webkit] UCM omnibox script injected at document-start")
883+ }
884+
885+ // Inject Wails runtime fetch interceptor for homepage bridging
886+ wailsBridge := `(() => { try { const origFetch = window.fetch.bind(window); const waiters = Object.create(null); window.__dumber_wails_resolve = (id, json) => { const w = waiters[id]; if(!w) return; delete waiters[id]; try { const headers = new Headers({"Content-Type":"application/json"}); w.resolve(new Response(json, { headers })); } catch(e){ w.reject(e); } }; window.fetch = (input, init) => { try { const url = new URL(input instanceof Request ? input.url : input, window.location.origin); if (url.pathname === '/wails/runtime') { const args = url.searchParams.get('args'); let payload = {}; try { payload = args ? JSON.parse(args) : {}; } catch(_){} const id = String(Date.now()) + '-' + Math.random().toString(36).slice(2); return new Promise((resolve, reject) => { waiters[id] = { resolve, reject }; try { window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({ type: 'wails', id, payload })); } catch (e) { reject(e); } }); } } catch(_){} return origFetch(input, init); }; } catch(_){} })();`
887+ cWails := C.CString(wailsBridge)
888+ defer C.free(unsafe.Pointer(cWails))
889+ wailsScript := C.webkit_user_script_new((*C.gchar)(cWails), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
890+ if wailsScript != nil {
891+ C.webkit_user_content_manager_add_script(w.native.ucm, wailsScript)
892+ C.webkit_user_script_unref(wailsScript)
893+ }
894+
895+ // No JS fallback bridge — native UCM is active
896+}
595897
596- // Inject color-scheme preference script at document-start to inform sites of system theme
597- preferDark := C.gtk_prefers_dark() != 0
598- if preferDark {
599- log.Printf("[theme] GTK prefers: dark")
600- } else {
601- log.Printf("[theme] GTK prefers: light")
602- }
603- var schemeJS string
604- if preferDark {
605- schemeJS = "(() => { try { const d=true; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:' + cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='dark light'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:dark;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();"
606- } else {
607- schemeJS = "(() => { try { const d=false; const cs=d?'dark':'light'; console.log('[dumber] color-scheme set:' + cs); try{ window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({type:'theme', value: cs})) }catch(_){} const meta=document.createElement('meta'); meta.name='color-scheme'; meta.content='light dark'; document.documentElement.appendChild(meta); const s=document.createElement('style'); s.textContent=':root{color-scheme:light;}'; document.documentElement.appendChild(s); const qD='(prefers-color-scheme: dark)'; const qL='(prefers-color-scheme: light)'; const orig=window.matchMedia; window.matchMedia=function(q){ if(typeof q==='string'&&(q.includes(qD)||q.includes(qL))){ const m={matches:q.includes('dark')?d:!d,media:q,onchange:null,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){},dispatchEvent(){return false;}}; return m;} return orig.call(window,q); }; } catch(e){ console.warn('[dumber] color-scheme injection failed', e) } })();"
608- }
609- cScheme := C.CString(schemeJS)
610- defer C.free(unsafe.Pointer(cScheme))
611- schemeScript := C.webkit_user_script_new((*C.gchar)(cScheme), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
612- if schemeScript != nil {
613- C.webkit_user_content_manager_add_script(w.native.ucm, schemeScript)
614- C.webkit_user_script_unref(schemeScript)
615- }
898+// startPeriodicGC starts a ticker to trigger JavaScript garbage collection periodically
899+func (w *WebView) startPeriodicGC(interval time.Duration) {
900+ if w == nil || w.gcTicker != nil {
901+ return // Already running or invalid
902+ }
903+
904+ w.gcTicker = time.NewTicker(interval)
905+
906+ go func() {
907+ defer func() {
908+ if w.gcTicker != nil {
909+ w.gcTicker.Stop()
910+ w.gcTicker = nil
911+ }
912+ }()
913+
914+ for {
915+ select {
916+ case <-w.gcTicker.C:
917+ w.triggerGC()
918+ case <-w.gcDone:
919+ return
920+ }
921+ }
922+ }()
923+}
616924
617- // Add user script at document-start
618- src := C.CString(omniboxDefaultScript)
619- defer C.free(unsafe.Pointer(src))
620- script := C.webkit_user_script_new((*C.gchar)(src), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
621- if script != nil {
622- C.webkit_user_content_manager_add_script(w.native.ucm, script)
623- C.webkit_user_script_unref(script)
624- log.Printf("[webkit] UCM omnibox script injected at document-start")
625- }
925+// stopPeriodicGC stops the periodic garbage collection
926+func (w *WebView) stopPeriodicGC() {
927+ if w == nil {
928+ return
929+ }
930+
931+ if w.gcDone != nil {
932+ close(w.gcDone)
933+ w.gcDone = nil
934+ }
935+
936+ if w.gcTicker != nil {
937+ w.gcTicker.Stop()
938+ w.gcTicker = nil
939+ }
940+}
626941
627- // Inject Wails runtime fetch interceptor for homepage bridging
628- wailsBridge := `(() => { try { const origFetch = window.fetch.bind(window); const waiters = Object.create(null); window.__dumber_wails_resolve = (id, json) => { const w = waiters[id]; if(!w) return; delete waiters[id]; try { const headers = new Headers({"Content-Type":"application/json"}); w.resolve(new Response(json, { headers })); } catch(e){ w.reject(e); } }; window.fetch = (input, init) => { try { const url = new URL(input instanceof Request ? input.url : input, window.location.origin); if (url.pathname === '/wails/runtime') { const args = url.searchParams.get('args'); let payload = {}; try { payload = args ? JSON.parse(args) : {}; } catch(_){} const id = String(Date.now()) + '-' + Math.random().toString(36).slice(2); return new Promise((resolve, reject) => { waiters[id] = { resolve, reject }; try { window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({ type: 'wails', id, payload })); } catch (e) { reject(e); } }); } } catch(_){} return origFetch(input, init); }; } catch(_){} })();`
629- cWails := C.CString(wailsBridge)
630- defer C.free(unsafe.Pointer(cWails))
631- wailsScript := C.webkit_user_script_new((*C.gchar)(cWails), C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
632- if wailsScript != nil {
633- C.webkit_user_content_manager_add_script(w.native.ucm, wailsScript)
634- C.webkit_user_script_unref(wailsScript)
635- }
942+// triggerGC manually triggers JavaScript garbage collection
943+func (w *WebView) triggerGC() {
944+ if w == nil || w.memStats == nil {
945+ return
946+ }
947+
948+ // Get default WebContext and trigger GC
949+ ctx := C.webkit_web_context_get_default()
950+ if ctx != nil {
951+ C.maybe_collect_js(ctx)
952+ w.memStats.lastGCTime = time.Now()
953+
954+ if w.config.Memory.EnableMemoryMonitoring {
955+ log.Printf("[webkit] JavaScript GC triggered (page loads: %d)", w.memStats.pageLoadCount)
956+ }
957+ }
958+}
959+
960+// GetMemoryStats returns current memory statistics for this WebView
961+func (w *WebView) GetMemoryStats() map[string]interface{} {
962+ if w == nil || w.memStats == nil {
963+ return nil
964+ }
965+
966+ return map[string]interface{}{
967+ "page_load_count": w.memStats.pageLoadCount,
968+ "last_gc_time": w.memStats.lastGCTime,
969+ "has_memory_pressure_settings": w.memStats.memoryPressureSettings != nil,
970+ }
971+}
972+
973+// TriggerMemoryCleanup manually triggers garbage collection and memory cleanup
974+func (w *WebView) TriggerMemoryCleanup() {
975+ if w == nil {
976+ return
977+ }
978+
979+ w.triggerGC()
636980
637- // No JS fallback bridge — native UCM is active
981+ if w.config.Memory.EnableMemoryMonitoring {
982+ log.Printf("[webkit] Manual memory cleanup triggered")
983+ }
638984 }
M pkg/webkit/window.go +4 -4
@@ -5,16 +5,16 @@ package webkit
55 // Window represents a top-level application window hosting a WebView.
66 // Stub for future GTK application window management.
77 type Window struct {
8- Title string
8+ Title string
99 }
1010
1111 // NewWindow constructs a Window. In real implementation, this would create a GTK window.
1212 func NewWindow(title string) (*Window, error) {
13- _ = title
14- return &Window{Title: title}, ErrNotImplemented
13+ _ = title
14+ return &Window{Title: title}, ErrNotImplemented
1515 }
1616
1717 // SetTitle updates the window title (no-op in non-CGO build).
1818 func (w *Window) SetTitle(title string) {
19- w.Title = title
19+ w.Title = title
2020 }
M pkg/webkit/window_cgo.go +19 -17
@@ -12,27 +12,29 @@ import "C"
1212 import "unsafe"
1313
1414 type Window struct {
15- win *C.GtkWidget
15+ win *C.GtkWidget
1616 }
1717
1818 func NewWindow(title string) (*Window, error) {
19- if C.gtk_init_check() == 0 {
20- return nil, ErrNotImplemented
21- }
22- // GTK4: gtk_window_new() returns GtkWindow*, no toplevel enum
23- w := (*C.GtkWidget)(unsafe.Pointer(C.gtk_window_new()))
24- if w == nil {
25- return nil, ErrNotImplemented
26- }
27- ctitle := C.CString(title)
28- defer C.free(unsafe.Pointer(ctitle))
29- C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w)), (*C.gchar)(ctitle))
30- return &Window{win: w}, nil
19+ if C.gtk_init_check() == 0 {
20+ return nil, ErrNotImplemented
21+ }
22+ // GTK4: gtk_window_new() returns GtkWindow*, no toplevel enum
23+ w := (*C.GtkWidget)(unsafe.Pointer(C.gtk_window_new()))
24+ if w == nil {
25+ return nil, ErrNotImplemented
26+ }
27+ ctitle := C.CString(title)
28+ defer C.free(unsafe.Pointer(ctitle))
29+ C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w)), (*C.gchar)(ctitle))
30+ return &Window{win: w}, nil
3131 }
3232
3333 func (w *Window) SetTitle(title string) {
34- if w == nil || w.win == nil { return }
35- ctitle := C.CString(title)
36- defer C.free(unsafe.Pointer(ctitle))
37- C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w.win)), (*C.gchar)(ctitle))
34+ if w == nil || w.win == nil {
35+ return
36+ }
37+ ctitle := C.CString(title)
38+ defer C.free(unsafe.Pointer(ctitle))
39+ C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w.win)), (*C.gchar)(ctitle))
3840 }
M pkg/webkit/zoom.go +18 -18
@@ -3,30 +3,30 @@
33 package webkit
44
55 const (
6- zoomMin = 0.25
7- zoomMax = 5.0
6+ zoomMin = 0.25
7+ zoomMax = 5.0
88 )
99
1010 // SetZoom sets the current zoom factor for the WebView.
1111 func (w *WebView) SetZoom(level float64) error {
12- if w == nil || w.destroyed {
13- return ErrNotImplemented
14- }
15- if level < zoomMin {
16- level = zoomMin
17- }
18- if level > zoomMax {
19- level = zoomMax
20- }
21- w.zoom = level
22- w.dispatchZoomChanged(w.zoom)
23- return nil
12+ if w == nil || w.destroyed {
13+ return ErrNotImplemented
14+ }
15+ if level < zoomMin {
16+ level = zoomMin
17+ }
18+ if level > zoomMax {
19+ level = zoomMax
20+ }
21+ w.zoom = level
22+ w.dispatchZoomChanged(w.zoom)
23+ return nil
2424 }
2525
2626 // GetZoom returns the current zoom factor.
2727 func (w *WebView) GetZoom() (float64, error) {
28- if w == nil || w.destroyed {
29- return 1.0, ErrNotImplemented
30- }
31- return w.zoom, nil
28+ if w == nil || w.destroyed {
29+ return 1.0, ErrNotImplemented
30+ }
31+ return w.zoom, nil
3232 }
M pkg/webkit/zoom_cgo.go +19 -19
@@ -9,26 +9,26 @@ package webkit
99 import "C"
1010
1111 func (w *WebView) SetZoom(level float64) error {
12- if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
13- return ErrNotImplemented
14- }
15- if level < 0.25 {
16- level = 0.25
17- }
18- if level > 5.0 {
19- level = 5.0
20- }
21- w.zoom = level
22- C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level))
23- w.dispatchZoomChanged(w.zoom)
24- return nil
12+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
13+ return ErrNotImplemented
14+ }
15+ if level < 0.25 {
16+ level = 0.25
17+ }
18+ if level > 5.0 {
19+ level = 5.0
20+ }
21+ w.zoom = level
22+ C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level))
23+ w.dispatchZoomChanged(w.zoom)
24+ return nil
2525 }
2626
2727 func (w *WebView) GetZoom() (float64, error) {
28- if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
29- return 1.0, ErrNotImplemented
30- }
31- z := float64(C.webkit_web_view_get_zoom_level(w.native.wv))
32- w.zoom = z
33- return z, nil
28+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
29+ return 1.0, ErrNotImplemented
30+ }
31+ z := float64(C.webkit_web_view_get_zoom_level(w.native.wv))
32+ w.zoom = z
33+ return z, nil
3434 }
M services/browser_service.go +164 -164
@@ -2,44 +2,44 @@
22 package services
33
44 import (
5- "context"
6- "database/sql"
7- "embed"
8- "fmt"
9- "errors"
10- "math"
11- "time"
12- neturl "net/url"
13-
14- "github.com/bnema/dumber/internal/config"
15- "github.com/bnema/dumber/internal/db"
16- "github.com/bnema/dumber/pkg/webkit"
5+ "context"
6+ "database/sql"
7+ "embed"
8+ "errors"
9+ "fmt"
10+ "math"
11+ neturl "net/url"
12+ "time"
13+
14+ "github.com/bnema/dumber/internal/config"
15+ "github.com/bnema/dumber/internal/db"
16+ "github.com/bnema/dumber/pkg/webkit"
1717 )
1818
1919 func clampToInt32(i int64) int32 {
20- if i > math.MaxInt32 {
21- i = math.MaxInt32
22- }
23- if i < math.MinInt32 {
24- i = math.MinInt32
25- }
26- return int32(i) //nolint:gosec // value is clamped to int32 bounds
20+ if i > math.MaxInt32 {
21+ i = math.MaxInt32
22+ }
23+ if i < math.MinInt32 {
24+ i = math.MinInt32
25+ }
26+ return int32(i) //nolint:gosec // value is clamped to int32 bounds
2727 }
2828
2929 // WindowTitleUpdater interface allows the service to update the window title
3030 // Decoupled from any specific GUI framework.
3131 type WindowTitleUpdater interface {
32- SetTitle(title string)
32+ SetTitle(title string)
3333 }
3434
3535 // BrowserService handles browser-related operations for the built-in browser.
3636 type BrowserService struct {
37- config *config.Config
38- dbQueries db.DatabaseQuerier
39- windowTitleUpdater WindowTitleUpdater
40- webView *webkit.WebView
41- initialURL string
42- injectableScript string
37+ config *config.Config
38+ dbQueries db.DatabaseQuerier
39+ windowTitleUpdater WindowTitleUpdater
40+ webView *webkit.WebView
41+ initialURL string
42+ injectableScript string
4343 }
4444
4545 // ServiceName returns the service name for frontend binding
@@ -69,14 +69,14 @@ type HistoryEntry struct {
6969
7070 // NewBrowserService creates a new BrowserService instance.
7171 func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService {
72- return &BrowserService{
73- config: cfg,
74- dbQueries: queries,
75- windowTitleUpdater: nil,
76- webView: nil,
77- initialURL: "",
78- injectableScript: "",
79- }
72+ return &BrowserService{
73+ config: cfg,
74+ dbQueries: queries,
75+ windowTitleUpdater: nil,
76+ webView: nil,
77+ initialURL: "",
78+ injectableScript: "",
79+ }
8080 }
8181
8282 // SetWindowTitleUpdater sets the window title updater interface
@@ -86,7 +86,7 @@ func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) {
8686
8787 // AttachWebView connects a native WebKit WebView to this service for integration.
8888 func (s *BrowserService) AttachWebView(view *webkit.WebView) {
89- s.webView = view
89+ s.webView = view
9090 }
9191
9292 // LoadInjectableScript loads the minified injectable controls script from assets
@@ -101,26 +101,26 @@ func (s *BrowserService) LoadInjectableScript(assets embed.FS) error {
101101
102102 // InjectControlScript injects the global controls script into the current page
103103 func (s *BrowserService) InjectControlScript(ctx context.Context) error {
104- _ = ctx
105- if s.webView == nil {
106- return fmt.Errorf("webview not attached")
107- }
108- if s.injectableScript == "" {
109- return fmt.Errorf("injectable script not loaded")
110- }
111- wrappedScript := "(function(){try{" +
112- "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',function(){try{" +
113- s.injectableScript +
114- "}catch(e){console.warn('Dumber Browser controls failed to load on DOMContentLoaded:', e);}});}else{" +
115- s.injectableScript +
116- "}}catch(e){console.warn('Dumber Browser controls injection failed:', e);}})();"
117- return s.webView.InjectScript(wrappedScript)
104+ _ = ctx
105+ if s.webView == nil {
106+ return fmt.Errorf("webview not attached")
107+ }
108+ if s.injectableScript == "" {
109+ return fmt.Errorf("injectable script not loaded")
110+ }
111+ wrappedScript := "(function(){try{" +
112+ "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',function(){try{" +
113+ s.injectableScript +
114+ "}catch(e){console.warn('Dumber Browser controls failed to load on DOMContentLoaded:', e);}});}else{" +
115+ s.injectableScript +
116+ "}}catch(e){console.warn('Dumber Browser controls injection failed:', e);}})();"
117+ return s.webView.InjectScript(wrappedScript)
118118 }
119119
120120 // Navigate handles navigation to a URL and records it in history.
121121 func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) {
122122 startTime := time.Now()
123-
123+
124124 if url == "" {
125125 return &NavigationResult{
126126 Success: false,
@@ -182,16 +182,16 @@ func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]His
182182
183183 result := make([]HistoryEntry, len(entries))
184184 for i, entry := range entries {
185- // Defensive cast to int32 to prevent overflow
186- vc := clampToInt32(entry.VisitCount.Int64)
187- result[i] = HistoryEntry{
188- ID: entry.ID,
189- URL: entry.Url,
190- Title: entry.Title.String,
191- VisitCount: vc,
192- LastVisited: entry.LastVisited.Time,
193- CreatedAt: entry.CreatedAt.Time,
194- }
185+ // Defensive cast to int32 to prevent overflow
186+ vc := clampToInt32(entry.VisitCount.Int64)
187+ result[i] = HistoryEntry{
188+ ID: entry.ID,
189+ URL: entry.Url,
190+ Title: entry.Title.String,
191+ VisitCount: vc,
192+ LastVisited: entry.LastVisited.Time,
193+ CreatedAt: entry.CreatedAt.Time,
194+ }
195195 }
196196
197197 return result, nil
@@ -209,7 +209,7 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit
209209
210210 urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
211211 titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
212-
212+
213213 entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
214214 if err != nil {
215215 return nil, err
@@ -217,15 +217,15 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit
217217
218218 result := make([]HistoryEntry, len(entries))
219219 for i, entry := range entries {
220- vc := clampToInt32(entry.VisitCount.Int64)
221- result[i] = HistoryEntry{
222- ID: entry.ID,
223- URL: entry.Url,
224- Title: entry.Title.String,
225- VisitCount: vc,
226- LastVisited: entry.LastVisited.Time,
227- CreatedAt: entry.CreatedAt.Time,
228- }
220+ vc := clampToInt32(entry.VisitCount.Int64)
221+ result[i] = HistoryEntry{
222+ ID: entry.ID,
223+ URL: entry.Url,
224+ Title: entry.Title.String,
225+ VisitCount: vc,
226+ LastVisited: entry.LastVisited.Time,
227+ CreatedAt: entry.CreatedAt.Time,
228+ }
229229 }
230230
231231 return result, nil
@@ -233,25 +233,25 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit
233233
234234 // DeleteHistoryEntry removes a specific history entry.
235235 func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
236- _ = ctx
237- _ = id
238- // Note: DeleteHistory method doesn't exist in current schema
239- // This would need to be implemented in the database layer
240- return fmt.Errorf("delete history not implemented yet")
236+ _ = ctx
237+ _ = id
238+ // Note: DeleteHistory method doesn't exist in current schema
239+ // This would need to be implemented in the database layer
240+ return fmt.Errorf("delete history not implemented yet")
241241 }
242242
243243 // ClearHistory removes all history entries.
244244 func (s *BrowserService) ClearHistory(ctx context.Context) error {
245- _ = ctx
246- // Note: ClearAllHistory method doesn't exist in current schema
247- // This would need to be implemented in the database layer
248- return fmt.Errorf("clear all history not implemented yet")
245+ _ = ctx
246+ // Note: ClearAllHistory method doesn't exist in current schema
247+ // This would need to be implemented in the database layer
248+ return fmt.Errorf("clear all history not implemented yet")
249249 }
250250
251251 // GetHistoryStats returns statistics about browser history.
252252 func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) {
253- // Get recent entries for basic stats
254- recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit)
253+ // Get recent entries for basic stats
254+ recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit)
255255 if err != nil {
256256 return nil, err
257257 }
@@ -273,16 +273,16 @@ func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interf
273273
274274 // GetConfig returns the current browser configuration.
275275 func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) {
276- _ = ctx
277- return s.config, nil
276+ _ = ctx
277+ return s.config, nil
278278 }
279279
280280 // UpdateConfig updates the browser configuration.
281281 func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error {
282- _ = ctx
283- // In a real implementation, you'd want to validate and persist the config
284- s.config = newConfig
285- return nil
282+ _ = ctx
283+ // In a real implementation, you'd want to validate and persist the config
284+ s.config = newConfig
285+ return nil
286286 }
287287
288288 // GetSearchShortcuts returns available search shortcuts.
@@ -297,7 +297,7 @@ func (s *BrowserService) recordHistory(ctx context.Context, url, title string) e
297297 if title != "" {
298298 titleNull = sql.NullString{String: title, Valid: true}
299299 }
300-
300+
301301 return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
302302 }
303303
@@ -310,13 +310,13 @@ func (s *BrowserService) ZoomIn(ctx context.Context, url string) (float64, error
310310 if err != nil {
311311 return 1.0, err
312312 }
313-
313+
314314 newZoom := s.getNextZoomLevel(currentZoom, true)
315315 err = s.SetZoomLevel(ctx, url, newZoom)
316316 if err != nil {
317317 return currentZoom, err
318318 }
319-
319+
320320 return newZoom, nil
321321 }
322322
@@ -326,13 +326,13 @@ func (s *BrowserService) ZoomOut(ctx context.Context, url string) (float64, erro
326326 if err != nil {
327327 return 1.0, err
328328 }
329-
329+
330330 newZoom := s.getNextZoomLevel(currentZoom, false)
331331 err = s.SetZoomLevel(ctx, url, newZoom)
332332 if err != nil {
333333 return currentZoom, err
334334 }
335-
335+
336336 return newZoom, nil
337337 }
338338
@@ -343,51 +343,51 @@ func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, er
343343
344344 // GetZoomLevel retrieves the saved zoom level for a URL.
345345 func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
346- if url == "" {
347- return 1.0, nil
348- }
349-
350- key := zoomKeyFromURL(url)
351- zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key)
352- if err != nil {
353- if errors.Is(err, sql.ErrNoRows) {
354- // Fallback to exact URL record if present (backward compatibility)
355- zl, err2 := s.dbQueries.GetZoomLevel(ctx, url)
356- if err2 == nil {
357- return zl, nil
358- }
359- // No zoom setting found, return default
360- return 1.0, nil
361- }
362- return 1.0, err
363- }
364-
365- return zoomLevel, nil
346+ if url == "" {
347+ return 1.0, nil
348+ }
349+
350+ key := zoomKeyFromURL(url)
351+ zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key)
352+ if err != nil {
353+ if errors.Is(err, sql.ErrNoRows) {
354+ // Fallback to exact URL record if present (backward compatibility)
355+ zl, err2 := s.dbQueries.GetZoomLevel(ctx, url)
356+ if err2 == nil {
357+ return zl, nil
358+ }
359+ // No zoom setting found, return default
360+ return 1.0, nil
361+ }
362+ return 1.0, err
363+ }
364+
365+ return zoomLevel, nil
366366 }
367367
368368 // SetZoomLevel sets the zoom level for a URL.
369369 func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {
370- if url == "" {
371- return fmt.Errorf("URL cannot be empty")
372- }
370+ if url == "" {
371+ return fmt.Errorf("URL cannot be empty")
372+ }
373373
374- // Clamp zoom level to reasonable bounds
375- if zoomLevel < zoomMin {
376- zoomLevel = zoomMin
377- } else if zoomLevel > zoomMax {
378- zoomLevel = zoomMax
379- }
374+ // Clamp zoom level to reasonable bounds
375+ if zoomLevel < zoomMin {
376+ zoomLevel = zoomMin
377+ } else if zoomLevel > zoomMax {
378+ zoomLevel = zoomMax
379+ }
380380
381- key := zoomKeyFromURL(url)
382- return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel)
381+ key := zoomKeyFromURL(url)
382+ return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel)
383383 }
384384
385385 // GetCurrentURL returns the current URL (this would be implemented by the frontend)
386386 func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) {
387- if s.webView == nil {
388- return "", nil
389- }
390- return s.webView.GetCurrentURL(), nil
387+ if s.webView == nil {
388+ return "", nil
389+ }
390+ return s.webView.GetCurrentURL(), nil
391391 }
392392
393393 // GetInitialURL returns the initially navigated URL for frontend synchronization
@@ -408,34 +408,34 @@ func (s *BrowserService) CopyCurrentURL(ctx context.Context, url string) error {
408408
409409 // GoBack provides navigation back functionality
410410 func (s *BrowserService) GoBack(ctx context.Context) error {
411- if s.webView != nil {
412- return s.webView.GoBack()
413- }
414- fmt.Println("Navigation: Go back")
415- return nil
411+ if s.webView != nil {
412+ return s.webView.GoBack()
413+ }
414+ fmt.Println("Navigation: Go back")
415+ return nil
416416 }
417417
418418 // GoForward provides navigation forward functionality
419419 func (s *BrowserService) GoForward(ctx context.Context) error {
420- if s.webView != nil {
421- return s.webView.GoForward()
422- }
423- fmt.Println("Navigation: Go forward")
424- return nil
420+ if s.webView != nil {
421+ return s.webView.GoForward()
422+ }
423+ fmt.Println("Navigation: Go forward")
424+ return nil
425425 }
426426
427427 // getNextZoomLevel returns the next Firefox zoom level in the specified direction
428428 const (
429- zoomMin = 0.25
430- zoomMax = 5.0
431- recentHistoryLimit = 1000
429+ zoomMin = 0.25
430+ zoomMax = 5.0
431+ recentHistoryLimit = 1000
432432 )
433433
434434 func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 {
435435 // Find the closest current zoom level
436436 closestIndex := 0
437437 minDiff := 10.0 // Large initial value
438-
438+
439439 for i, level := range firefoxZoomLevels {
440440 diff := currentZoom - level
441441 if diff < 0 {
@@ -446,38 +446,38 @@ func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) floa
446446 closestIndex = i
447447 }
448448 }
449-
450- if zoomIn {
451- // Move to next higher zoom level
452- if closestIndex < len(firefoxZoomLevels)-1 {
453- return firefoxZoomLevels[closestIndex+1]
454- }
455- return firefoxZoomLevels[closestIndex] // Already at max
456- }
457- // Move to next lower zoom level
458- if closestIndex > 0 {
459- return firefoxZoomLevels[closestIndex-1]
460- }
461- return firefoxZoomLevels[closestIndex] // Already at min
449+
450+ if zoomIn {
451+ // Move to next higher zoom level
452+ if closestIndex < len(firefoxZoomLevels)-1 {
453+ return firefoxZoomLevels[closestIndex+1]
454+ }
455+ return firefoxZoomLevels[closestIndex] // Already at max
456+ }
457+ // Move to next lower zoom level
458+ if closestIndex > 0 {
459+ return firefoxZoomLevels[closestIndex-1]
460+ }
461+ return firefoxZoomLevels[closestIndex] // Already at min
462462 }
463463
464464 // setZoom sets the zoom level to a specific value
465465 func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) {
466- err := s.SetZoomLevel(ctx, url, zoomLevel)
467- if err != nil {
468- return 1.0, err
469- }
470- return zoomLevel, nil
466+ err := s.SetZoomLevel(ctx, url, zoomLevel)
467+ if err != nil {
468+ return 1.0, err
469+ }
470+ return zoomLevel, nil
471471 }
472472
473473 // zoomKeyFromURL extracts a stable per-domain key for zoom persistence.
474474 // Uses hostname if present; falls back to the full input URL string.
475475 func zoomKeyFromURL(raw string) string {
476- u, err := neturl.Parse(raw)
477- if err != nil || u.Host == "" {
478- return raw
479- }
480- return u.Host
476+ u, err := neturl.Parse(raw)
477+ if err != nil || u.Host == "" {
478+ return raw
479+ }
480+ return u.Host
481481 }
482482
483483 // extractDomain extracts the domain from a URL for zoom level storage
M services/config_service.go +84 -84
@@ -1,30 +1,30 @@
11 package services
22
33 import (
4- "context"
5- "fmt"
6- "os"
7- "path/filepath"
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
88
9- "github.com/bnema/dumber/internal/config"
10- "strings"
9+ "github.com/bnema/dumber/internal/config"
10+ "strings"
1111 )
1212
1313 // ConfigService handles configuration operations for the application.
1414 type ConfigService struct {
15- config *config.Config
16- configPath string
15+ config *config.Config
16+ configPath string
1717 }
1818
1919 const defaultDirPerm = 0o750
2020
2121 // ConfigInfo represents configuration information for the frontend.
2222 type ConfigInfo struct {
23- ConfigPath string `json:"config_path"`
24- DatabasePath string `json:"database_path"`
25- SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
26- DmenuSettings *config.DmenuConfig `json:"dmenu_settings"`
27- RenderingMode string `json:"rendering_mode"`
23+ ConfigPath string `json:"config_path"`
24+ DatabasePath string `json:"database_path"`
25+ SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
26+ DmenuSettings *config.DmenuConfig `json:"dmenu_settings"`
27+ RenderingMode string `json:"rendering_mode"`
2828 }
2929
3030 // NewConfigService creates a new ConfigService instance.
@@ -37,29 +37,29 @@ func NewConfigService(cfg *config.Config, configPath string) *ConfigService {
3737
3838 // GetConfigInfo returns comprehensive configuration information.
3939 func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error) {
40- _ = ctx
41- return &ConfigInfo{
42- ConfigPath: s.configPath,
43- DatabasePath: s.config.Database.Path,
44- SearchShortcuts: s.config.SearchShortcuts,
45- DmenuSettings: &s.config.Dmenu,
46- RenderingMode: string(s.config.RenderingMode),
47- }, nil
40+ _ = ctx
41+ return &ConfigInfo{
42+ ConfigPath: s.configPath,
43+ DatabasePath: s.config.Database.Path,
44+ SearchShortcuts: s.config.SearchShortcuts,
45+ DmenuSettings: &s.config.Dmenu,
46+ RenderingMode: string(s.config.RenderingMode),
47+ }, nil
4848 }
4949
5050 // GetSearchShortcuts returns all configured search shortcuts.
5151 func (s *ConfigService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
52- _ = ctx
53- return s.config.SearchShortcuts, nil
52+ _ = ctx
53+ return s.config.SearchShortcuts, nil
5454 }
5555
5656 // AddSearchShortcut adds a new search shortcut.
5757 func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error {
58- _ = ctx
59- if key == "" {
60- return fmt.Errorf("shortcut key cannot be empty")
61- }
62-
58+ _ = ctx
59+ if key == "" {
60+ return fmt.Errorf("shortcut key cannot be empty")
61+ }
62+
6363 if shortcut.URL == "" {
6464 return fmt.Errorf("shortcut URL cannot be empty")
6565 }
@@ -74,10 +74,10 @@ func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, short
7474
7575 // UpdateSearchShortcut updates an existing search shortcut.
7676 func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error {
77- _ = ctx
78- if key == "" {
79- return fmt.Errorf("shortcut key cannot be empty")
80- }
77+ _ = ctx
78+ if key == "" {
79+ return fmt.Errorf("shortcut key cannot be empty")
80+ }
8181
8282 if _, exists := s.config.SearchShortcuts[key]; !exists {
8383 return fmt.Errorf("shortcut '%s' does not exist", key)
@@ -93,10 +93,10 @@ func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, sh
9393
9494 // DeleteSearchShortcut removes a search shortcut.
9595 func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) error {
96- _ = ctx
97- if key == "" {
98- return fmt.Errorf("shortcut key cannot be empty")
99- }
96+ _ = ctx
97+ if key == "" {
98+ return fmt.Errorf("shortcut key cannot be empty")
99+ }
100100
101101 if _, exists := s.config.SearchShortcuts[key]; !exists {
102102 return fmt.Errorf("shortcut '%s' does not exist", key)
@@ -108,16 +108,16 @@ func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) er
108108
109109 // GetDmenuSettings returns current dmenu settings.
110110 func (s *ConfigService) GetDmenuSettings(ctx context.Context) (*config.DmenuConfig, error) {
111- _ = ctx
112- return &s.config.Dmenu, nil
111+ _ = ctx
112+ return &s.config.Dmenu, nil
113113 }
114114
115115 // UpdateDmenuSettings updates dmenu configuration.
116116 func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *config.DmenuConfig) error {
117- _ = ctx
118- if settings == nil {
119- return fmt.Errorf("dmenu settings cannot be nil")
120- }
117+ _ = ctx
118+ if settings == nil {
119+ return fmt.Errorf("dmenu settings cannot be nil")
120+ }
121121
122122 s.config.Dmenu = *settings
123123 return s.saveConfig()
@@ -125,22 +125,22 @@ func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *confi
125125
126126 // GetDatabasePath returns the current database path.
127127 func (s *ConfigService) GetDatabasePath(ctx context.Context) (string, error) {
128- _ = ctx
129- return s.config.Database.Path, nil
128+ _ = ctx
129+ return s.config.Database.Path, nil
130130 }
131131
132132 // SetDatabasePath updates the database path.
133133 func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error {
134- _ = ctx
135- if path == "" {
136- return fmt.Errorf("database path cannot be empty")
137- }
134+ _ = ctx
135+ if path == "" {
136+ return fmt.Errorf("database path cannot be empty")
137+ }
138138
139139 // Validate that the directory exists or can be created
140140 dir := filepath.Dir(path)
141- if err := os.MkdirAll(dir, defaultDirPerm); err != nil {
142- return fmt.Errorf("failed to create database directory: %w", err)
143- }
141+ if err := os.MkdirAll(dir, defaultDirPerm); err != nil {
142+ return fmt.Errorf("failed to create database directory: %w", err)
143+ }
144144
145145 s.config.Database.Path = path
146146 return s.saveConfig()
@@ -148,28 +148,28 @@ func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error
148148
149149 // GetDefaultBrowser returns the default browser setting.
150150 func (s *ConfigService) GetDefaultBrowser(ctx context.Context) (string, error) {
151- _ = ctx
152- // Default browser is now built into the app
153- return "dumber (built-in)", nil
151+ _ = ctx
152+ // Default browser is now built into the app
153+ return "dumber (built-in)", nil
154154 }
155155
156156 // SetDefaultBrowser updates the default browser setting.
157157 func (s *ConfigService) SetDefaultBrowser(ctx context.Context, browser string) error {
158- _ = ctx
159- _ = browser
160- // Default browser setting is not needed for built-in browser
161- return fmt.Errorf("default browser is built-in and cannot be changed")
158+ _ = ctx
159+ _ = browser
160+ // Default browser setting is not needed for built-in browser
161+ return fmt.Errorf("default browser is built-in and cannot be changed")
162162 }
163163
164164 // ReloadConfig reloads configuration from file.
165165 func (s *ConfigService) ReloadConfig(ctx context.Context) error {
166- _ = ctx
167- // Create new manager and load config
168- manager, err := config.NewManager()
169- if err != nil {
170- return fmt.Errorf("failed to create config manager: %w", err)
171- }
172-
166+ _ = ctx
167+ // Create new manager and load config
168+ manager, err := config.NewManager()
169+ if err != nil {
170+ return fmt.Errorf("failed to create config manager: %w", err)
171+ }
172+
173173 if err := manager.Load(); err != nil {
174174 return fmt.Errorf("failed to load config: %w", err)
175175 }
@@ -180,8 +180,8 @@ func (s *ConfigService) ReloadConfig(ctx context.Context) error {
180180
181181 // ValidateConfig validates the current configuration.
182182 func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) {
183- _ = ctx
184- var errors []string
183+ _ = ctx
184+ var errors []string
185185
186186 // Validate database path
187187 if s.config.Database.Path == "" {
@@ -219,16 +219,16 @@ func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) {
219219
220220 // GetConfigPath returns the path to the configuration file.
221221 func (s *ConfigService) GetConfigPath(ctx context.Context) (string, error) {
222- _ = ctx
223- return s.configPath, nil
222+ _ = ctx
223+ return s.configPath, nil
224224 }
225225
226226 // ExportConfig returns the current configuration as JSON.
227227 func (s *ConfigService) ExportConfig(ctx context.Context) (*config.Config, error) {
228- _ = ctx
229- // Return a copy of the config to prevent modifications
230- configCopy := *s.config
231- return &configCopy, nil
228+ _ = ctx
229+ // Return a copy of the config to prevent modifications
230+ configCopy := *s.config
231+ return &configCopy, nil
232232 }
233233
234234 // ImportConfig imports configuration from a Config object.
@@ -255,21 +255,21 @@ func (s *ConfigService) ImportConfig(ctx context.Context, newConfig *config.Conf
255255
256256 // GetRenderingMode returns the current rendering mode (auto|gpu|cpu).
257257 func (s *ConfigService) GetRenderingMode(ctx context.Context) (string, error) {
258- _ = ctx
259- return string(s.config.RenderingMode), nil
258+ _ = ctx
259+ return string(s.config.RenderingMode), nil
260260 }
261261
262262 // SetRenderingMode updates the rendering mode (auto|gpu|cpu).
263263 func (s *ConfigService) SetRenderingMode(ctx context.Context, mode string) error {
264- _ = ctx
265- m := strings.ToLower(strings.TrimSpace(mode))
266- switch m {
267- case "auto", "gpu", "cpu":
268- s.config.RenderingMode = config.RenderingMode(m)
269- return s.saveConfig()
270- default:
271- return fmt.Errorf("invalid rendering mode: %s", mode)
272- }
264+ _ = ctx
265+ m := strings.ToLower(strings.TrimSpace(mode))
266+ switch m {
267+ case "auto", "gpu", "cpu":
268+ s.config.RenderingMode = config.RenderingMode(m)
269+ return s.saveConfig()
270+ default:
271+ return fmt.Errorf("invalid rendering mode: %s", mode)
272+ }
273273 }
274274
275275 // saveConfig saves the current configuration to file.
M services/parser_service.go +57 -57
@@ -12,7 +12,7 @@ import (
1212
1313 // ParserService wraps the parser functionality for the application.
1414 type ParserService struct {
15- parser *parser.Parser
15+ parser *parser.Parser
1616 dbQueries *db.Queries
1717 }
1818
@@ -20,93 +20,93 @@ type ParserService struct {
2020 func NewParserService(cfg *config.Config, queries *db.Queries) *ParserService {
2121 // Create history provider from database
2222 historyProvider := &DatabaseHistoryProvider{queries: queries}
23-
23+
2424 // Create parser with configuration
2525 p := parser.NewParser(cfg, historyProvider)
26-
26+
2727 return &ParserService{
28- parser: p,
28+ parser: p,
2929 dbQueries: queries,
3030 }
3131 }
3232
3333 // ParseInput parses user input and returns navigation result.
3434 func (s *ParserService) ParseInput(ctx context.Context, input string) (*parser.ParseResult, error) {
35- _ = ctx
36- if input == "" {
37- return nil, fmt.Errorf("input cannot be empty")
38- }
39-
35+ _ = ctx
36+ if input == "" {
37+ return nil, fmt.Errorf("input cannot be empty")
38+ }
39+
4040 return s.parser.ParseInput(input)
4141 }
4242
4343 // GetCompletions provides URL/search completions based on input.
4444 func (s *ParserService) GetCompletions(ctx context.Context, input string, limit int) ([]string, error) {
45- _ = ctx
46- if limit <= 0 {
47- limit = 10 // Default limit
48- }
49-
45+ _ = ctx
46+ if limit <= 0 {
47+ limit = 10 // Default limit
48+ }
49+
5050 return s.parser.SuggestCompletions(input, limit)
5151 }
5252
5353 // FuzzySearchHistory performs fuzzy search on history entries.
5454 func (s *ParserService) FuzzySearchHistory(
55- ctx context.Context, query string, threshold float64,
55+ ctx context.Context, query string, threshold float64,
5656 ) ([]parser.FuzzyMatch, error) {
57- _ = ctx
58- if threshold <= 0 {
59- threshold = 0.3 // Default threshold
60- }
61-
57+ _ = ctx
58+ if threshold <= 0 {
59+ threshold = 0.3 // Default threshold
60+ }
61+
6262 return s.parser.FuzzySearchHistory(query, threshold)
6363 }
6464
6565 // GetSupportedShortcuts returns configured search shortcuts.
6666 func (s *ParserService) GetSupportedShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
67- _ = ctx
68- return s.parser.GetSupportedShortcuts(), nil
67+ _ = ctx
68+ return s.parser.GetSupportedShortcuts(), nil
6969 }
7070
7171 // ProcessShortcut processes a shortcut and returns the resulting URL.
7272 func (s *ParserService) ProcessShortcut(ctx context.Context, shortcut, query string) (string, error) {
73- _ = ctx
74- shortcuts := s.parser.GetSupportedShortcuts()
75- return s.parser.ProcessShortcut(shortcut, query, shortcuts)
73+ _ = ctx
74+ shortcuts := s.parser.GetSupportedShortcuts()
75+ return s.parser.ProcessShortcut(shortcut, query, shortcuts)
7676 }
7777
7878 // CalculateSimilarity calculates similarity between two strings.
7979 func (s *ParserService) CalculateSimilarity(ctx context.Context, s1, s2 string) (float64, error) {
80- _ = ctx
81- return s.parser.CalculateSimilarity(s1, s2), nil
80+ _ = ctx
81+ return s.parser.CalculateSimilarity(s1, s2), nil
8282 }
8383
8484 // ValidateURL checks if the input represents a valid URL.
8585 func (s *ParserService) ValidateURL(ctx context.Context, input string) (bool, error) {
86- _ = ctx
87- return s.parser.IsValidURL(input), nil
86+ _ = ctx
87+ return s.parser.IsValidURL(input), nil
8888 }
8989
9090 // PreviewParse provides a preview of what ParseInput would return.
9191 func (s *ParserService) PreviewParse(ctx context.Context, input string) (*parser.ParseResult, error) {
92- _ = ctx
93- if input == "" {
94- return nil, fmt.Errorf("input cannot be empty")
95- }
96-
97- return s.parser.PreviewParse(input)
92+ _ = ctx
93+ if input == "" {
94+ return nil, fmt.Errorf("input cannot be empty")
95+ }
96+
97+ return s.parser.PreviewParse(input)
9898 }
9999
100100 // GetFuzzyConfig returns current fuzzy matching configuration.
101101 func (s *ParserService) GetFuzzyConfig(ctx context.Context) (*parser.FuzzyConfig, error) {
102- _ = ctx
103- return s.parser.GetFuzzyConfig(), nil
102+ _ = ctx
103+ return s.parser.GetFuzzyConfig(), nil
104104 }
105105
106106 // UpdateFuzzyConfig updates the fuzzy matching configuration.
107107 func (s *ParserService) UpdateFuzzyConfig(ctx context.Context, config *parser.FuzzyConfig) error {
108- _ = ctx
109- return s.parser.UpdateFuzzyConfig(config)
108+ _ = ctx
109+ return s.parser.UpdateFuzzyConfig(config)
110110 }
111111
112112 // DatabaseHistoryProvider implements HistoryProvider using the database.
@@ -117,82 +117,82 @@ type DatabaseHistoryProvider struct {
117117 // GetRecentHistory returns recent history entries limited by count.
118118 func (h *DatabaseHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) {
119119 ctx := context.Background()
120-
120+
121121 entries, err := h.queries.GetHistory(ctx, int64(limit))
122122 if err != nil {
123123 return nil, err
124124 }
125-
125+
126126 // Convert to pointer slice
127127 result := make([]*db.History, len(entries))
128128 for i, entry := range entries {
129129 entryCopy := entry
130130 result[i] = &entryCopy
131131 }
132-
132+
133133 return result, nil
134134 }
135135
136136 // GetAllHistory returns all history entries for fuzzy search.
137137 func (h *DatabaseHistoryProvider) GetAllHistory() ([]*db.History, error) {
138- ctx := context.Background()
139-
140- // Use a large limit to get all history
141- const allHistoryLimit = 10000
142- entries, err := h.queries.GetHistory(ctx, allHistoryLimit)
138+ ctx := context.Background()
139+
140+ // Use a large limit to get all history
141+ const allHistoryLimit = 10000
142+ entries, err := h.queries.GetHistory(ctx, allHistoryLimit)
143143 if err != nil {
144144 return nil, err
145145 }
146-
146+
147147 // Convert to pointer slice
148148 result := make([]*db.History, len(entries))
149149 for i, entry := range entries {
150150 entryCopy := entry
151151 result[i] = &entryCopy
152152 }
153-
153+
154154 return result, nil
155155 }
156156
157157 // SearchHistory performs a basic text search in history.
158158 func (h *DatabaseHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) {
159159 ctx := context.Background()
160-
160+
161161 // Use SQL LIKE pattern for basic search
162162 urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
163163 titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
164-
164+
165165 entries, err := h.queries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
166166 if err != nil {
167167 return nil, err
168168 }
169-
169+
170170 // Convert to pointer slice
171171 result := make([]*db.History, len(entries))
172172 for i, entry := range entries {
173173 entryCopy := entry
174174 result[i] = &entryCopy
175175 }
176-
176+
177177 return result, nil
178178 }
179179
180180 // GetHistoryByURL retrieves history entry by exact URL match.
181181 func (h *DatabaseHistoryProvider) GetHistoryByURL(url string) (*db.History, error) {
182182 ctx := context.Background()
183-
183+
184184 // Search for exact URL match using SearchHistory
185185 urlPattern := sql.NullString{String: url, Valid: true}
186186 emptyPattern := sql.NullString{Valid: false}
187-
187+
188188 entries, err := h.queries.SearchHistory(ctx, urlPattern, emptyPattern, 1)
189189 if err != nil {
190190 return nil, err
191191 }
192-
192+
193193 if len(entries) == 0 {
194194 return nil, fmt.Errorf("history entry not found for URL: %s", url)
195195 }
196-
196+
197197 return &entries[0], nil
198198 }
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 (
77 "math"
88 "testing"
99
10- "github.com/bnema/dumber/services"
1110 "github.com/bnema/dumber/internal/config"
1211 mock_db "github.com/bnema/dumber/internal/db/mocks"
12+ "github.com/bnema/dumber/services"
1313 "github.com/golang/mock/gomock"
1414 )
1515
1616 // floatMatcher implements gomock.Matcher for floating point comparison with tolerance
1717 type floatMatcher struct {
18- expected float64
18+ expected float64
1919 tolerance float64
2020 }
2121
2222 func (f floatMatcher) Matches(x interface{}) bool {
2323 if actual, ok := x.(float64); ok {
24- return math.Abs(actual - f.expected) < f.tolerance
24+ return math.Abs(actual-f.expected) < f.tolerance
2525 }
2626 return false
2727 }
@@ -55,112 +55,112 @@ func TestBrowserService_ZoomControls_Contract(t *testing.T) {
5555 mockDB := mock_db.NewMockDatabaseQuerier(ctrl)
5656 cfg := &config.Config{}
5757 service := services.NewBrowserService(cfg, mockDB)
58-
58+
5959 ctx := context.Background()
6060 testURL := "https://example.com"
61-
61+
6262 t.Run("ZoomIn increases zoom level by 0.1", func(t *testing.T) {
6363 // Setup mock expectations
6464 mockDB.EXPECT().
6565 GetZoomLevel(ctx, testURL).
6666 Return(1.0, nil)
67-
67+
6868 mockDB.EXPECT().
6969 SetZoomLevel(ctx, testURL, floatEq(1.1)).
7070 Return(nil)
71-
71+
7272 // Contract: ZoomIn should increase zoom by 0.1 and return new level
7373 newZoom, err := service.ZoomIn(ctx, testURL)
74-
74+
7575 // Contract assertions
7676 if err != nil {
7777 t.Fatalf("ZoomIn should not return error: %v", err)
7878 }
79-
79+
8080 expectedZoom := 1.1
8181 if newZoom != expectedZoom {
8282 t.Errorf("Expected zoom level %f, got %f", expectedZoom, newZoom)
8383 }
8484 })
85-
85+
8686 t.Run("ZoomOut decreases zoom level by 0.1", func(t *testing.T) {
8787 // Setup mock expectations for current zoom level
8888 mockDB.EXPECT().
8989 GetZoomLevel(ctx, testURL).
9090 Return(1.2, nil)
91-
91+
9292 mockDB.EXPECT().
9393 SetZoomLevel(ctx, testURL, floatEq(1.1)).
9494 Return(nil)
95-
95+
9696 // Contract: ZoomOut should decrease zoom by 0.1 and return new level
9797 newZoom, err := service.ZoomOut(ctx, testURL)
98-
98+
9999 // Contract assertions
100100 if err != nil {
101101 t.Fatalf("ZoomOut should not return error: %v", err)
102102 }
103-
103+
104104 expectedZoom := 1.1
105- if math.Abs(newZoom - expectedZoom) > 1e-10 {
105+ if math.Abs(newZoom-expectedZoom) > 1e-10 {
106106 t.Errorf("Expected zoom level %f, got %f", expectedZoom, newZoom)
107107 }
108108 })
109-
109+
110110 t.Run("ZoomReset sets zoom to 1.0", func(t *testing.T) {
111111 // Setup mock expectations
112112 mockDB.EXPECT().
113113 SetZoomLevel(ctx, testURL, floatEq(1.0)).
114114 Return(nil)
115-
115+
116116 // Contract: ZoomReset should always return zoom to default (1.0)
117117 newZoom, err := service.ResetZoom(ctx, testURL)
118-
118+
119119 // Contract assertions
120120 if err != nil {
121121 t.Fatalf("ResetZoom should not return error: %v", err)
122122 }
123-
123+
124124 expectedZoom := 1.0
125125 if newZoom != expectedZoom {
126126 t.Errorf("ResetZoom should return 1.0, got %f", expectedZoom)
127127 }
128128 })
129-
129+
130130 t.Run("GetZoomLevel returns current zoom level", func(t *testing.T) {
131131 // Setup mock expectations
132132 expectedZoom := 1.5
133133 mockDB.EXPECT().
134134 GetZoomLevel(ctx, testURL).
135135 Return(expectedZoom, nil)
136-
136+
137137 // Contract: GetZoomLevel should return current zoom level
138138 currentZoom, err := service.GetZoomLevel(ctx, testURL)
139-
139+
140140 // Contract assertions
141141 if err != nil {
142142 t.Fatalf("GetZoomLevel should not return error: %v", err)
143143 }
144-
144+
145145 if currentZoom != expectedZoom {
146146 t.Errorf("Expected zoom level %f, got %f", expectedZoom, currentZoom)
147147 }
148148 })
149-
149+
150150 t.Run("GetZoomLevel returns default 1.0 for new URLs", func(t *testing.T) {
151151 // Setup mock expectations for non-existent URL
152152 mockDB.EXPECT().
153153 GetZoomLevel(ctx, testURL).
154154 Return(0.0, sql.ErrNoRows)
155-
155+
156156 // Contract: GetZoomLevel should return 1.0 for URLs without zoom settings
157157 currentZoom, err := service.GetZoomLevel(ctx, testURL)
158-
158+
159159 // Contract assertions
160160 if err != nil {
161161 t.Fatalf("GetZoomLevel should not return error for new URLs: %v", err)
162162 }
163-
163+
164164 expectedZoom := 1.0
165165 if currentZoom != expectedZoom {
166166 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) {
176176 mockDB := mock_db.NewMockDatabaseQuerier(ctrl)
177177 cfg := &config.Config{}
178178 service := services.NewBrowserService(cfg, mockDB)
179-
179+
180180 ctx := context.Background()
181-
181+
182182 t.Run("GoBack handles gracefully when no history", func(t *testing.T) {
183183 // Contract: GoBack should handle empty history gracefully
184184 err := service.GoBack(ctx)
185-
185+
186186 // May return error for empty history, which is acceptable
187187 if err != nil {
188188 t.Logf("GoBack returned error (acceptable for empty history): %v", err)
189189 }
190190 })
191-
191+
192192 t.Run("GoForward handles gracefully when no forward history", func(t *testing.T) {
193193 // Contract: GoForward should handle empty forward history gracefully
194194 err := service.GoForward(ctx)
195-
195+
196196 // May return error for empty forward history, which is acceptable
197197 if err != nil {
198198 t.Logf("GoForward returned error (acceptable for empty forward history): %v", err)
@@ -208,18 +208,18 @@ func TestBrowserService_WindowTitleUpdate_Contract(t *testing.T) {
208208 mockDB := mock_db.NewMockDatabaseQuerier(ctrl)
209209 cfg := &config.Config{}
210210 service := services.NewBrowserService(cfg, mockDB)
211-
211+
212212 // Setup mock window title updater
213213 mockUpdater := &MockWindowTitleUpdater{}
214214 service.SetWindowTitleUpdater(mockUpdater)
215-
215+
216216 t.Run("SetWindowTitleUpdater accepts WindowTitleUpdater interface", func(t *testing.T) {
217217 // Contract: Service should accept any WindowTitleUpdater implementation
218218 newMockUpdater := &MockWindowTitleUpdater{}
219-
219+
220220 // This should not panic or error
221221 service.SetWindowTitleUpdater(newMockUpdater)
222-
222+
223223 // Verify the updater was set properly
224224 if newMockUpdater.callCount < 0 {
225225 t.Errorf("WindowTitleUpdater should be properly initialized")
@@ -235,31 +235,31 @@ func TestBrowserService_URLOperations_Contract(t *testing.T) {
235235 mockDB := mock_db.NewMockDatabaseQuerier(ctrl)
236236 cfg := &config.Config{}
237237 service := services.NewBrowserService(cfg, mockDB)
238-
238+
239239 t.Run("GetCurrentURL returns current URL", func(t *testing.T) {
240240 // Contract: GetCurrentURL should return the current URL string
241241 ctx := context.Background()
242-
242+
243243 url, err := service.GetCurrentURL(ctx)
244-
244+
245245 // Contract assertions
246246 if err != nil {
247247 t.Fatalf("GetCurrentURL should not return error: %v", err)
248248 }
249-
249+
250250 // URL should be a valid string (can be empty for new browser)
251251 if url != "" && len(url) < 4 { // Minimum valid URL length
252252 t.Errorf("Invalid URL returned: %s", url)
253253 }
254254 })
255-
255+
256256 t.Run("CopyCurrentURL accepts URL parameter", func(t *testing.T) {
257257 // Contract: CopyCurrentURL should accept URL and handle clipboard
258258 ctx := context.Background()
259259 testURL := "https://example.com"
260-
260+
261261 err := service.CopyCurrentURL(ctx, testURL)
262-
262+
263263 // Contract assertions - should not return error for valid URL
264264 if err != nil {
265265 t.Fatalf("CopyCurrentURL should not return error for valid URL: %v", err)
@@ -276,27 +276,27 @@ func TestBrowserService_ErrorHandling_Contract(t *testing.T) {
276276 mockDB := mock_db.NewMockDatabaseQuerier(ctrl)
277277 service := services.NewBrowserService(nil, mockDB)
278278 ctx := context.Background()
279-
279+
280280 t.Run("Service handles nil config gracefully", func(t *testing.T) {
281281 // Contract: Service methods should handle nil config gracefully
282-
282+
283283 // Setup mock expectations for operations that might still work
284284 mockDB.EXPECT().
285285 GetZoomLevel(ctx, "test").
286286 Return(1.0, nil).
287287 AnyTimes()
288-
288+
289289 mockDB.EXPECT().
290290 SetZoomLevel(ctx, gomock.Any(), gomock.Any()).
291291 Return(nil).
292292 AnyTimes()
293-
293+
294294 // These should work with valid database but nil config
295295 _, err := service.ZoomIn(ctx, "test")
296296 if err != nil {
297297 t.Logf("ZoomIn returned error with nil config (may be expected): %v", err)
298298 }
299-
299+
300300 err = service.GoBack(ctx)
301301 if err != nil {
302302 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 @@
11 package contract
22
33 import (
4- "testing"
5- "github.com/bnema/dumber/pkg/webkit"
4+ "github.com/bnema/dumber/pkg/webkit"
5+ "testing"
66 )
77
88 // Contract: RegisterKeyboardShortcut binds accelerator without error and triggers callback on press.
99 func Test_RegisterKeyboardShortcut_Contract(t *testing.T) {
10- view, err := webkit.NewWebView(&webkit.Config{})
11- if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12- err = view.RegisterKeyboardShortcut("Ctrl+L", func() {})
13- if err != nil { t.Fatalf("RegisterKeyboardShortcut should succeed, got: %v", err) }
10+ view, err := webkit.NewWebView(&webkit.Config{})
11+ if err != nil {
12+ t.Fatalf("NewWebView failed: %v", err)
13+ }
14+ err = view.RegisterKeyboardShortcut("Ctrl+L", func() {})
15+ if err != nil {
16+ t.Fatalf("RegisterKeyboardShortcut should succeed, got: %v", err)
17+ }
1418 }
M tests/contract/test_lifecycle.go +15 -7
@@ -1,15 +1,23 @@
11 package contract
22
33 import (
4- "testing"
5- "github.com/bnema/dumber/pkg/webkit"
4+ "github.com/bnema/dumber/pkg/webkit"
5+ "testing"
66 )
77
88 // Contract: Show/Hide/Destroy lifecycle operations succeed without error.
99 func Test_Lifecycle_Contract(t *testing.T) {
10- view, err := webkit.NewWebView(&webkit.Config{})
11- if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12- if err := view.Show(); err != nil { t.Fatalf("Show should succeed: %v", err) }
13- if err := view.Hide(); err != nil { t.Fatalf("Hide should succeed: %v", err) }
14- if err := view.Destroy(); err != nil { t.Fatalf("Destroy should succeed: %v", err) }
10+ view, err := webkit.NewWebView(&webkit.Config{})
11+ if err != nil {
12+ t.Fatalf("NewWebView failed: %v", err)
13+ }
14+ if err := view.Show(); err != nil {
15+ t.Fatalf("Show should succeed: %v", err)
16+ }
17+ if err := view.Hide(); err != nil {
18+ t.Fatalf("Hide should succeed: %v", err)
19+ }
20+ if err := view.Destroy(); err != nil {
21+ t.Fatalf("Destroy should succeed: %v", err)
22+ }
1523 }
M tests/contract/test_navigation.go +9 -9
@@ -1,17 +1,17 @@
11 package contract
22
33 import (
4- "testing"
5- "github.com/bnema/dumber/pkg/webkit"
4+ "github.com/bnema/dumber/pkg/webkit"
5+ "testing"
66 )
77
88 // Contract: LoadURL navigates to a valid URL without error.
99 func Test_LoadURL_Navigation_Contract(t *testing.T) {
10- view, err := webkit.NewWebView(&webkit.Config{})
11- if err != nil {
12- t.Fatalf("NewWebView failed: %v", err)
13- }
14- if err := view.LoadURL("https://example.com"); err != nil {
15- t.Fatalf("LoadURL should succeed, got: %v", err)
16- }
10+ view, err := webkit.NewWebView(&webkit.Config{})
11+ if err != nil {
12+ t.Fatalf("NewWebView failed: %v", err)
13+ }
14+ if err := view.LoadURL("https://example.com"); err != nil {
15+ t.Fatalf("LoadURL should succeed, got: %v", err)
16+ }
1717 }
M tests/contract/test_script_injection.go +9 -7
@@ -1,15 +1,17 @@
11 package contract
22
33 import (
4- "testing"
5- "github.com/bnema/dumber/pkg/webkit"
4+ "github.com/bnema/dumber/pkg/webkit"
5+ "testing"
66 )
77
88 // Contract: InjectScript executes JS without error once implemented.
99 func Test_InjectScript_Contract(t *testing.T) {
10- view, err := webkit.NewWebView(&webkit.Config{})
11- if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12- if err := view.InjectScript("console.log('hello')"); err != nil {
13- t.Fatalf("InjectScript should succeed, got: %v", err)
14- }
10+ view, err := webkit.NewWebView(&webkit.Config{})
11+ if err != nil {
12+ t.Fatalf("NewWebView failed: %v", err)
13+ }
14+ if err := view.InjectScript("console.log('hello')"); err != nil {
15+ t.Fatalf("InjectScript should succeed, got: %v", err)
16+ }
1517 }
M tests/contract/test_webview_creation.go +10 -10
@@ -1,19 +1,19 @@
11 package contract
22
33 import (
4- "testing"
4+ "testing"
55
6- "github.com/bnema/dumber/pkg/webkit"
6+ "github.com/bnema/dumber/pkg/webkit"
77 )
88
99 // Contract: NewWebView creates a WebView without error and allows initial configuration.
1010 func Test_NewWebView_Contract(t *testing.T) {
11- cfg := &webkit.Config{InitialURL: "https://example.com", ZoomDefault: 1.0}
12- view, err := webkit.NewWebView(cfg)
13- if err != nil {
14- t.Fatalf("expected NewWebView to succeed, got error: %v", err)
15- }
16- if view == nil {
17- t.Fatalf("expected non-nil WebView")
18- }
11+ cfg := &webkit.Config{InitialURL: "https://example.com", ZoomDefault: 1.0}
12+ view, err := webkit.NewWebView(cfg)
13+ if err != nil {
14+ t.Fatalf("expected NewWebView to succeed, got error: %v", err)
15+ }
16+ if view == nil {
17+ t.Fatalf("expected non-nil WebView")
18+ }
1919 }
M tests/contract/test_zoom_control.go +16 -10
@@ -1,18 +1,24 @@
11 package contract
22
33 import (
4- "testing"
5- "github.com/bnema/dumber/pkg/webkit"
4+ "github.com/bnema/dumber/pkg/webkit"
5+ "testing"
66 )
77
88 // Contract: SetZoom/GetZoom operate without error and persist value.
99 func Test_ZoomControl_Contract(t *testing.T) {
10- view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0})
11- if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12- if err := view.SetZoom(1.25); err != nil {
13- t.Fatalf("SetZoom should succeed, got: %v", err)
14- }
15- z, err := view.GetZoom()
16- if err != nil { t.Fatalf("GetZoom should succeed, got: %v", err) }
17- if z != 1.25 { t.Fatalf("expected zoom 1.25, got %v", z) }
10+ view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0})
11+ if err != nil {
12+ t.Fatalf("NewWebView failed: %v", err)
13+ }
14+ if err := view.SetZoom(1.25); err != nil {
15+ t.Fatalf("SetZoom should succeed, got: %v", err)
16+ }
17+ z, err := view.GetZoom()
18+ if err != nil {
19+ t.Fatalf("GetZoom should succeed, got: %v", err)
20+ }
21+ if z != 1.25 {
22+ t.Fatalf("expected zoom 1.25, got %v", z)
23+ }
1824 }
M tests/integration/browser_controls_integration_test.go +61 -61
@@ -34,14 +34,14 @@ func setupTestDB(t *testing.T) (*sql.DB, *db.Queries, func()) {
3434 // Create temporary database file
3535 tmpDir := t.TempDir()
3636 dbPath := filepath.Join(tmpDir, "test_browser.db")
37-
37+
3838 database, err := sql.Open("sqlite3", dbPath)
3939 if err != nil {
4040 t.Fatalf("Failed to open test database: %v", err)
4141 }
42-
42+
4343 // Create tables using the migration schema
44- createTablesSQL := `
44+ createTablesSQL := `
4545 CREATE TABLE IF NOT EXISTS history (
4646 id INTEGER PRIMARY KEY AUTOINCREMENT,
4747 url TEXT NOT NULL,
@@ -65,29 +65,29 @@ func setupTestDB(t *testing.T) (*sql.DB, *db.Queries, func()) {
6565 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
6666 );
6767 `
68-
68+
6969 if _, err := database.Exec(createTablesSQL); err != nil {
7070 t.Fatalf("Failed to create test tables: %v", err)
7171 }
72-
72+
7373 queries := db.New(database)
74-
74+
7575 cleanup := func() {
7676 database.Close()
7777 os.Remove(dbPath)
7878 }
79-
79+
8080 return database, queries, cleanup
8181 }
8282
8383 // setupTestBrowserService creates a BrowserService with test database
8484 func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) {
8585 _, queries, cleanup := setupTestDB(t)
86-
86+
8787 cfg := &config.Config{}
88-
88+
8989 service := services.NewBrowserService(cfg, queries)
90-
90+
9191 return service, cleanup
9292 }
9393
@@ -95,109 +95,109 @@ func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) {
9595 func TestBrowserControls_ZoomPersistence_Integration(t *testing.T) {
9696 service, cleanup := setupTestBrowserService(t)
9797 defer cleanup()
98-
98+
9999 ctx := context.Background()
100100 testURL := "https://example.com"
101-
101+
102102 t.Run("Zoom level persists across service operations", func(t *testing.T) {
103103 // Set initial zoom level
104104 zoomLevel, err := service.ZoomIn(ctx, testURL)
105105 if err != nil {
106106 t.Fatalf("Failed to zoom in: %v", err)
107107 }
108-
108+
109109 expectedZoom := 1.1
110110 if zoomLevel != expectedZoom {
111111 t.Errorf("Expected zoom %f, got %f", expectedZoom, zoomLevel)
112112 }
113-
113+
114114 // Zoom in again
115115 zoomLevel, err = service.ZoomIn(ctx, testURL)
116116 if err != nil {
117117 t.Fatalf("Failed to zoom in again: %v", err)
118118 }
119-
119+
120120 expectedZoom = 1.2
121121 if zoomLevel != expectedZoom {
122122 t.Errorf("Expected zoom %f, got %f", expectedZoom, zoomLevel)
123123 }
124-
124+
125125 // Test that zoom persists for the same URL
126126 // Simulate getting zoom level (this would typically happen on page load)
127127 currentZoom, err := service.GetZoomLevel(ctx, testURL)
128128 if err != nil {
129129 t.Fatalf("Failed to get zoom level: %v", err)
130130 }
131-
131+
132132 if currentZoom != expectedZoom {
133133 t.Errorf("Zoom level not persisted. Expected %f, got %f", expectedZoom, currentZoom)
134134 }
135135 })
136-
136+
137137 t.Run("Different URLs have independent zoom levels", func(t *testing.T) {
138138 url1 := "https://example.com"
139139 url2 := "https://google.com"
140-
140+
141141 // Set different zoom levels for different URLs
142142 zoom1, err := service.ZoomIn(ctx, url1)
143143 if err != nil {
144144 t.Fatalf("Failed to zoom in for url1: %v", err)
145145 }
146-
146+
147147 zoom2, err := service.ZoomOut(ctx, url2)
148148 if err != nil {
149149 t.Fatalf("Failed to zoom out for url2: %v", err)
150150 }
151-
151+
152152 // Verify they are different
153153 if zoom1 == zoom2 {
154154 t.Errorf("Expected different zoom levels for different URLs, both got %f", zoom1)
155155 }
156-
156+
157157 // Verify persistence for each URL
158158 persistedZoom1, err := service.GetZoomLevel(ctx, url1)
159159 if err != nil {
160160 t.Fatalf("Failed to get zoom level for url1: %v", err)
161161 }
162-
162+
163163 persistedZoom2, err := service.GetZoomLevel(ctx, url2)
164164 if err != nil {
165165 t.Fatalf("Failed to get zoom level for url2: %v", err)
166166 }
167-
167+
168168 if persistedZoom1 != zoom1 {
169169 t.Errorf("URL1 zoom not persisted. Expected %f, got %f", zoom1, persistedZoom1)
170170 }
171-
171+
172172 if persistedZoom2 != zoom2 {
173173 t.Errorf("URL2 zoom not persisted. Expected %f, got %f", zoom2, persistedZoom2)
174174 }
175175 })
176-
176+
177177 t.Run("Zoom reset works correctly", func(t *testing.T) {
178178 testURL := "https://test.com"
179-
179+
180180 // Change zoom level
181181 service.ZoomIn(ctx, testURL)
182182 service.ZoomIn(ctx, testURL)
183-
183+
184184 // Reset zoom
185185 resetZoom, err := service.ResetZoom(ctx, testURL)
186186 if err != nil {
187187 t.Fatalf("Failed to reset zoom: %v", err)
188188 }
189-
189+
190190 expectedZoom := 1.0
191191 if resetZoom != expectedZoom {
192192 t.Errorf("Expected reset zoom %f, got %f", expectedZoom, resetZoom)
193193 }
194-
194+
195195 // Verify persistence of reset
196196 persistedZoom, err := service.GetZoomLevel(ctx, testURL)
197197 if err != nil {
198198 t.Fatalf("Failed to get zoom level after reset: %v", err)
199199 }
200-
200+
201201 if persistedZoom != expectedZoom {
202202 t.Errorf("Reset zoom not persisted. Expected %f, got %f", expectedZoom, persistedZoom)
203203 }
@@ -208,24 +208,24 @@ func TestBrowserControls_ZoomPersistence_Integration(t *testing.T) {
208208 func TestBrowserControls_NavigationHistory_Integration(t *testing.T) {
209209 _, cleanup := setupTestBrowserService(t)
210210 defer cleanup()
211-
211+
212212 ctx := context.Background()
213-
213+
214214 t.Run("Navigation builds history correctly", func(t *testing.T) {
215215 // This test assumes the service has methods to add history entries
216216 // and that navigation uses real history data
217-
217+
218218 // Add some test history entries directly to database for testing
219219 database, queries, dbCleanup := setupTestDB(t)
220220 defer dbCleanup()
221-
221+
222222 // Insert test history entries
223223 testURLs := []string{
224224 "https://example.com",
225- "https://google.com",
225+ "https://google.com",
226226 "https://github.com",
227227 }
228-
228+
229229 for _, url := range testURLs {
230230 _, err := database.Exec(
231231 `INSERT INTO history (url, title, visit_count) VALUES (?, ?, ?)`,
@@ -235,16 +235,16 @@ func TestBrowserControls_NavigationHistory_Integration(t *testing.T) {
235235 t.Fatalf("Failed to insert test history: %v", err)
236236 }
237237 }
238-
238+
239239 // Create service with populated database
240240 serviceWithHistory := services.NewBrowserService(&config.Config{}, queries)
241-
241+
242242 // Test navigation operations
243243 err := serviceWithHistory.GoBack(ctx)
244244 if err != nil {
245245 t.Logf("GoBack returned error (acceptable for test): %v", err)
246246 }
247-
247+
248248 err = serviceWithHistory.GoForward(ctx)
249249 if err != nil {
250250 t.Logf("GoForward returned error (acceptable for test): %v", err)
@@ -256,29 +256,29 @@ func TestBrowserControls_NavigationHistory_Integration(t *testing.T) {
256256 func TestBrowserControls_WindowTitleUpdater_Integration(t *testing.T) {
257257 service, cleanup := setupTestBrowserService(t)
258258 defer cleanup()
259-
259+
260260 // Create mock window title updater
261261 mockUpdater := &MockWindowUpdater{
262262 titles: make([]string, 0),
263263 }
264-
264+
265265 // This test would need the actual WindowTitleUpdater interface to be implemented
266266 // For now, we test that the service accepts the updater
267267 t.Run("Service integrates with WindowTitleUpdater", func(t *testing.T) {
268268 // This is a placeholder test - would need actual implementation
269269 // to verify title updates during navigation operations
270-
270+
271271 service.SetWindowTitleUpdater(mockUpdater)
272-
272+
273273 // Simulate navigation that should trigger title update
274274 ctx := context.Background()
275275 service.GoBack(ctx)
276-
276+
277277 // In a full integration test, we would verify that:
278278 // 1. Title updates were called
279279 // 2. Correct titles were set
280280 // 3. Updates happened at appropriate times
281-
281+
282282 // For now, just verify no panic occurred
283283 if len(mockUpdater.titles) < 0 {
284284 t.Errorf("WindowTitleUpdater integration failed")
@@ -290,17 +290,17 @@ func TestBrowserControls_WindowTitleUpdater_Integration(t *testing.T) {
290290 func TestBrowserControls_URLCopying_Integration(t *testing.T) {
291291 service, cleanup := setupTestBrowserService(t)
292292 defer cleanup()
293-
293+
294294 t.Run("URL copying returns current browser URL", func(t *testing.T) {
295295 ctx := context.Background()
296-
296+
297297 // Test URL copying functionality
298298 testURL := "https://example.com"
299299 err := service.CopyCurrentURL(ctx, testURL)
300300 if err != nil {
301301 t.Fatalf("CopyCurrentURL failed: %v", err)
302302 }
303-
303+
304304 // In a full integration test, we would also verify:
305305 // 1. System clipboard is updated
306306 // 2. Correct URL format
@@ -312,38 +312,38 @@ func TestBrowserControls_URLCopying_Integration(t *testing.T) {
312312 func TestBrowserControls_Performance_Integration(t *testing.T) {
313313 service, cleanup := setupTestBrowserService(t)
314314 defer cleanup()
315-
315+
316316 ctx := context.Background()
317317 testURL := "https://example.com"
318-
318+
319319 t.Run("Zoom operations complete within performance requirements", func(t *testing.T) {
320320 // Spec requires <50ms for history search, similar expectation for zoom
321321 start := time.Now()
322-
322+
323323 _, err := service.ZoomIn(ctx, testURL)
324324 if err != nil {
325325 t.Fatalf("ZoomIn failed: %v", err)
326326 }
327-
327+
328328 duration := time.Since(start)
329-
329+
330330 // Zoom operations should be fast (<10ms typically)
331331 maxDuration := 50 * time.Millisecond
332332 if duration > maxDuration {
333333 t.Errorf("Zoom operation too slow: %v (max: %v)", duration, maxDuration)
334334 }
335335 })
336-
336+
337337 t.Run("Navigation operations complete within reasonable time", func(t *testing.T) {
338338 start := time.Now()
339-
339+
340340 err := service.GoBack(ctx)
341341 if err != nil {
342342 t.Fatalf("GoBack failed: %v", err)
343343 }
344-
344+
345345 duration := time.Since(start)
346-
346+
347347 // Navigation should be reasonably fast
348348 maxDuration := 100 * time.Millisecond
349349 if duration > maxDuration {
@@ -355,15 +355,15 @@ func TestBrowserControls_Performance_Integration(t *testing.T) {
355355 // Helper function to verify database state
356356 func verifyZoomInDatabase(t *testing.T, queries *db.Queries, url string, expectedZoom float64) {
357357 _ = context.Background() // Test placeholder
358-
358+
359359 // This would use the actual SQLC generated methods
360360 // For now, it's a placeholder for the verification logic
361-
361+
362362 // zoom, err := queries.GetZoomLevel(ctx, url)
363363 // if err != nil {
364364 // t.Fatalf("Failed to get zoom from database: %v", err)
365365 // }
366- //
366+ //
367367 // if zoom != expectedZoom {
368368 // t.Errorf("Database zoom mismatch. Expected %f, got %f", expectedZoom, zoom)
369369 // }
M tests/integration/test_external_shortcuts.go +9 -7
@@ -1,15 +1,17 @@
11 package integration
22
33 import (
4- "testing"
5- "github.com/bnema/dumber/pkg/webkit"
4+ "github.com/bnema/dumber/pkg/webkit"
5+ "testing"
66 )
77
88 // Integration: keyboard shortcuts work on external sites once implemented.
99 func Test_WebKit_ExternalShortcuts_Integration(t *testing.T) {
10- view, err := webkit.NewWebView(&webkit.Config{})
11- if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12- if err := view.RegisterKeyboardShortcut("Ctrl+K", func(){}); err != nil {
13- t.Fatalf("RegisterKeyboardShortcut should succeed, got: %v", err)
14- }
10+ view, err := webkit.NewWebView(&webkit.Config{})
11+ if err != nil {
12+ t.Fatalf("NewWebView failed: %v", err)
13+ }
14+ if err := view.RegisterKeyboardShortcut("Ctrl+K", func() {}); err != nil {
15+ t.Fatalf("RegisterKeyboardShortcut should succeed, got: %v", err)
16+ }
1517 }
M tests/integration/test_google_navigation.go +9 -7
@@ -1,15 +1,17 @@
11 package integration
22
33 import (
4- "testing"
5- "github.com/bnema/dumber/pkg/webkit"
4+ "github.com/bnema/dumber/pkg/webkit"
5+ "testing"
66 )
77
88 // Integration: navigate to google.fr without error once implemented.
99 func Test_WebKit_GoogleNavigation_Integration(t *testing.T) {
10- view, err := webkit.NewWebView(&webkit.Config{})
11- if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12- if err := view.LoadURL("https://www.google.fr"); err != nil {
13- t.Fatalf("LoadURL should succeed, got: %v", err)
14- }
10+ view, err := webkit.NewWebView(&webkit.Config{})
11+ if err != nil {
12+ t.Fatalf("NewWebView failed: %v", err)
13+ }
14+ if err := view.LoadURL("https://www.google.fr"); err != nil {
15+ t.Fatalf("LoadURL should succeed, got: %v", err)
16+ }
1517 }
M tests/integration/test_webkit_basic.go +9 -7
@@ -1,15 +1,17 @@
11 package integration
22
33 import (
4- "testing"
5- "github.com/bnema/dumber/pkg/webkit"
4+ "github.com/bnema/dumber/pkg/webkit"
5+ "testing"
66 )
77
88 // Integration: basic WebKit window and navigation works.
99 func Test_WebKit_BasicWindow_Integration(t *testing.T) {
10- view, err := webkit.NewWebView(&webkit.Config{InitialURL: "about:blank"})
11- if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12- if err := view.LoadURL("https://example.com"); err != nil {
13- t.Fatalf("LoadURL should succeed, got: %v", err)
14- }
10+ view, err := webkit.NewWebView(&webkit.Config{InitialURL: "about:blank"})
11+ if err != nil {
12+ t.Fatalf("NewWebView failed: %v", err)
13+ }
14+ if err := view.LoadURL("https://example.com"); err != nil {
15+ t.Fatalf("LoadURL should succeed, got: %v", err)
16+ }
1517 }
M tests/integration/test_zoom_persistence.go +16 -10
@@ -1,18 +1,24 @@
11 package integration
22
33 import (
4- "testing"
5- "github.com/bnema/dumber/pkg/webkit"
4+ "github.com/bnema/dumber/pkg/webkit"
5+ "testing"
66 )
77
88 // Integration: zoom persists across operations (with DB) once implemented.
99 func Test_WebKit_ZoomPersistence_Integration(t *testing.T) {
10- view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0})
11- if err != nil { t.Fatalf("NewWebView failed: %v", err) }
12- if err := view.SetZoom(1.1); err != nil {
13- t.Fatalf("SetZoom should succeed, got: %v", err)
14- }
15- z, err := view.GetZoom()
16- if err != nil { t.Fatalf("GetZoom should succeed, got: %v", err) }
17- if z != 1.1 { t.Fatalf("expected zoom 1.1, got %v", z) }
10+ view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0})
11+ if err != nil {
12+ t.Fatalf("NewWebView failed: %v", err)
13+ }
14+ if err := view.SetZoom(1.1); err != nil {
15+ t.Fatalf("SetZoom should succeed, got: %v", err)
16+ }
17+ z, err := view.GetZoom()
18+ if err != nil {
19+ t.Fatalf("GetZoom should succeed, got: %v", err)
20+ }
21+ if z != 1.1 {
22+ t.Fatalf("expected zoom 1.1, got %v", z)
23+ }
1824 }
M tests/unit/test_error_handling.go +10 -11
@@ -1,19 +1,18 @@
11 package unit
22
33 import (
4- "errors"
5- "testing"
6- "github.com/bnema/dumber/pkg/webkit"
4+ "errors"
5+ "github.com/bnema/dumber/pkg/webkit"
6+ "testing"
77 )
88
99 // Unit: WebKit error handling should propagate errors with context once implemented.
1010 func Test_WebKit_ErrorHandling(t *testing.T) {
11- _, err := webkit.NewWebView(nil)
12- if err == nil {
13- t.Fatalf("expected error prior to implementation (RED phase)")
14- }
15- if !errors.Is(err, webkit.ErrNotImplemented) {
16- t.Fatalf("expected ErrNotImplemented, got %v", err)
17- }
11+ _, err := webkit.NewWebView(nil)
12+ if err == nil {
13+ t.Fatalf("expected error prior to implementation (RED phase)")
14+ }
15+ if !errors.Is(err, webkit.ErrNotImplemented) {
16+ t.Fatalf("expected ErrNotImplemented, got %v", err)
17+ }
1818 }
19-
M tests/unit/test_memory.go +5 -6
@@ -1,14 +1,13 @@
11 package unit
22
33 import (
4- "testing"
5- "github.com/bnema/dumber/pkg/webkit"
4+ "github.com/bnema/dumber/pkg/webkit"
5+ "testing"
66 )
77
88 // Memory test placeholder: baseline <100MB once implemented.
99 func Test_WebKit_Memory_Baseline(t *testing.T) {
10- if _, err := webkit.NewWebView(&webkit.Config{}); err == nil {
11- t.Fatalf("expected construction to fail prior to implementation (RED phase)")
12- }
10+ if _, err := webkit.NewWebView(&webkit.Config{}); err == nil {
11+ t.Fatalf("expected construction to fail prior to implementation (RED phase)")
12+ }
1313 }
14-
M tests/unit/test_memory_management.go +5 -6
@@ -1,14 +1,13 @@
11 package unit
22
33 import (
4- "testing"
5- "github.com/bnema/dumber/pkg/webkit"
4+ "github.com/bnema/dumber/pkg/webkit"
5+ "testing"
66 )
77
88 // Unit: CGO memory management semantics (finalizers, ownership) to be verified post-implementation.
99 func Test_WebKit_CGOMemory_Management(t *testing.T) {
10- if _, err := webkit.NewWebView(&webkit.Config{}); err == nil {
11- t.Fatalf("expected construction to fail prior to implementation (RED phase)")
12- }
10+ if _, err := webkit.NewWebView(&webkit.Config{}); err == nil {
11+ t.Fatalf("expected construction to fail prior to implementation (RED phase)")
12+ }
1313 }
14-
M tests/unit/test_performance.go +5 -6
@@ -1,14 +1,13 @@
11 package unit
22
33 import (
4- "testing"
5- "github.com/bnema/dumber/pkg/webkit"
4+ "github.com/bnema/dumber/pkg/webkit"
5+ "testing"
66 )
77
88 // Performance test placeholder: startup time <500ms (to be measured once implemented).
99 func Test_WebKit_Performance_StartupTime(t *testing.T) {
10- if _, err := webkit.NewWebView(&webkit.Config{}); err == nil {
11- t.Fatalf("expected construction to fail prior to implementation (RED phase)")
12- }
10+ if _, err := webkit.NewWebView(&webkit.Config{}); err == nil {
11+ t.Fatalf("expected construction to fail prior to implementation (RED phase)")
12+ }
1313 }
14-

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close