v0.4.0 6203a22

main.go RawHistoryFolder

1package main
2
3import (
4	"context"
5	"embed"
6	"encoding/json"
7	"fmt"
8	"log"
9	"mime"
10	neturl "net/url"
11	"os"
12	"os/exec"
13	"os/signal"
14	"path/filepath"
15	"runtime"
16	"strconv"
17	"strings"
18	"syscall"
19
20	"github.com/bnema/dumber/internal/cli"
21	"github.com/bnema/dumber/internal/config"
22	"github.com/bnema/dumber/internal/db"
23	"github.com/bnema/dumber/pkg/clipboard"
24	"github.com/bnema/dumber/pkg/webkit"
25	"github.com/bnema/dumber/services"
26)
27
28// Build information set via ldflags
29var (
30	version   = "dev"
31	commit    = "none"
32	buildDate = "unknown"
33)
34
35func main() {
36	// Check if we should run the CLI mode
37	if shouldRunCLI() {
38		runCLI()
39		return
40	}
41
42	// Otherwise run the GUI browser
43	runBrowser()
44}
45
46// shouldRunCLI determines if we should run in CLI mode based on arguments
47func shouldRunCLI() bool {
48	// No args = GUI landing page mode
49	if len(os.Args) <= 1 {
50		return false
51	}
52
53	// Check for GUI-specific flags
54	for _, arg := range os.Args[1:] {
55		if arg == "--gui" || arg == "-g" {
56			return false // Explicit GUI mode
57		}
58	}
59
60	// Check for browse command - this should open GUI in direct navigation mode
61	if len(os.Args) >= 2 && os.Args[1] == "browse" {
62		return false // Browse command uses GUI mode but navigates directly
63	}
64
65	// Any other arguments mean CLI mode
66	return true
67}
68
69// runCLI executes the CLI functionality using the existing CLI package
70func runCLI() {
71	// Initialize configuration system
72	if err := config.Init(); err != nil {
73		fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err)
74		os.Exit(1)
75	}
76
77	// Start configuration watching for live reload
78	if err := config.Watch(); err != nil {
79		// Don't exit on watch error, just warn
80		fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err)
81	}
82
83	rootCmd := cli.NewRootCmd(version, commit, buildDate)
84
85	// Handle dmenu flag at the root level for direct integration
86	if len(os.Args) > 1 && os.Args[1] == "--dmenu" {
87		// Create a temporary CLI instance to handle dmenu mode
88		cliInstance, err := cli.NewCLI()
89		if err != nil {
90			fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err)
91			os.Exit(1)
92		}
93		defer func() {
94			if closeErr := cliInstance.Close(); closeErr != nil {
95				fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
96			}
97		}()
98
99		// Generate dmenu options
100		dmenuCmd := cli.NewDmenuCmd()
101		if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil {
102			fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err)
103			os.Exit(1)
104		}
105		return
106	}
107
108	// Execute the root command
109	if err := rootCmd.Execute(); err != nil {
110		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
111		os.Exit(1)
112	}
113}
114
115//go:embed frontend/dist
116var assets embed.FS
117
118// runBrowser launches a minimal native WebKit window and integrates services.
119func runBrowser() {
120	log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable())
121	// GTK requires all UI calls to run on the main OS thread
122	if webkit.IsNativeAvailable() {
123		runtime.LockOSThread()
124		defer runtime.UnlockOSThread()
125	}
126
127	// Proactively harden media pipeline to avoid WebProcess crashes from buggy VAAPI/plugins.
128	// Can be disabled with DUMBER_MEDIA_SAFE=0
129	if os.Getenv("DUMBER_MEDIA_SAFE") != "0" {
130		// Demote VAAPI-related elements so they won't be auto-picked
131		if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" {
132			os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0")
133		}
134		// Provide a safe audio sink to avoid autoaudiosink failures
135		if os.Getenv("GST_AUDIO_SINK") == "" {
136			os.Setenv("GST_AUDIO_SINK", "fakesink")
137		}
138		// Optionally disable media stream features that can engage complex pipelines
139		if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" {
140			os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1")
141		}
142		log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)")
143	}
144
145	if err := config.Init(); err != nil {
146		log.Fatalf("Failed to initialize config: %v", err)
147	}
148	cfg := config.Get()
149	log.Printf("Config initialized")
150
151	// Detect keyboard layout/locale and hint to webkit layer
152	detectAndSetKeyboardLocale()
153
154	// Initialize database
155	database, err := db.InitDB(cfg.Database.Path)
156	if err != nil {
157		log.Fatalf("Failed to initialize database: %v", err)
158	}
159	defer database.Close()
160	queries := db.New(database)
161	log.Printf("Database opened at %s", cfg.Database.Path)
162
163	// Initialize services
164	parserService := services.NewParserService(cfg, queries)
165	browserService := services.NewBrowserService(cfg, queries)
166	// JS controls injection is disabled; using native shortcuts via GTK.
167
168	// Track current URL for zoom persistence
169	// Initialize to the initial homepage so shortcuts work immediately
170	var currentURL string = "dumb://homepage"
171
172	// Register custom scheme resolver for dumb://
173	webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) {
174		log.Printf("[scheme] request: %s", uri)
175		// Known forms:
176		// - dumb://homepage or dumb:homepage → index.html
177		// - dumb://app/index.html, dumb://app/<path> → serve from frontend/dist/<path>
178		// - dumb://<anything> without path → index.html
179		u, err := neturl.Parse(uri)
180		if err != nil || u.Scheme != "dumb" {
181			return "", nil, false
182		}
183		// API routes
184		if u.Host == "api" || strings.HasPrefix(u.Opaque, "api") || strings.HasPrefix(u.Path, "/api") {
185			// Normalize path for opaque or hierarchical forms
186			path := u.Path
187			if path == "" && u.Opaque != "" {
188				// e.g., dumb:api/config or dumb:api/history/recent
189				parts := strings.SplitN(u.Opaque, ":", 2)
190				if len(parts) == 2 {
191					path = "/" + parts[1]
192				}
193			}
194			if strings.HasPrefix(path, "/api/") {
195				path = strings.TrimPrefix(path, "/api")
196			}
197			if path == "/api" {
198				path = "/"
199			}
200			switch {
201			case strings.HasPrefix(path, "/rendering/status"):
202				log.Printf("[api] GET /rendering/status")
203				// Report configured rendering mode; runtime GPU state depends on WebKit internals
204				resp := struct {
205					Mode string `json:"mode"`
206				}{Mode: string(cfg.RenderingMode)}
207				b, _ := json.Marshal(resp)
208				return "application/json; charset=utf-8", b, true
209			case strings.HasPrefix(path, "/config"):
210				log.Printf("[api] GET /config")
211				// Build config info
212				cfgPath, _ := config.GetConfigFile()
213				info := struct {
214					ConfigPath      string                           `json:"config_path"`
215					DatabasePath    string                           `json:"database_path"`
216					SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
217					Appearance      config.AppearanceConfig          `json:"appearance"`
218				}{
219					ConfigPath:      cfgPath,
220					DatabasePath:    cfg.Database.Path,
221					SearchShortcuts: cfg.SearchShortcuts,
222					Appearance:      cfg.Appearance,
223				}
224				b, _ := json.Marshal(info)
225				return "application/json; charset=utf-8", b, true
226			case strings.HasPrefix(path, "/history/recent"):
227				log.Printf("[api] GET /history/recent%s", u.RawQuery)
228				// Parse limit
229				q := u.Query()
230				limit := 50
231				if l := q.Get("limit"); l != "" {
232					if n, err := strconv.Atoi(l); err == nil && n > 0 {
233						limit = n
234					}
235				}
236				ctx := context.Background()
237				entries, err := browserService.GetRecentHistory(ctx, limit)
238				if err != nil {
239					return "application/json; charset=utf-8", []byte("[]"), true
240				}
241				b, _ := json.Marshal(entries)
242				return "application/json; charset=utf-8", b, true
243			case strings.HasPrefix(path, "/history/search"):
244				log.Printf("[api] GET /history/search%s", u.RawQuery)
245				q := u.Query()
246				query := q.Get("q")
247				limit := 50
248				if l := q.Get("limit"); l != "" {
249					if n, err := strconv.Atoi(l); err == nil && n > 0 {
250						limit = n
251					}
252				}
253				ctx := context.Background()
254				entries, err := browserService.SearchHistory(ctx, query, limit)
255				if err != nil {
256					return "application/json; charset=utf-8", []byte("[]"), true
257				}
258				b, _ := json.Marshal(entries)
259				return "application/json; charset=utf-8", b, true
260			case strings.HasPrefix(path, "/history/stats"):
261				log.Printf("[api] GET /history/stats")
262				ctx := context.Background()
263				stats, err := browserService.GetHistoryStats(ctx)
264				if err != nil {
265					return "application/json; charset=utf-8", []byte("{}"), true
266				}
267				b, _ := json.Marshal(stats)
268				return "application/json; charset=utf-8", b, true
269			default:
270				return "application/json; charset=utf-8", []byte("{}"), true
271			}
272		}
273		// Resolve target path inside embed FS
274		var rel string
275		if u.Opaque == "homepage" || (u.Host == "homepage" && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) {
276			rel = "index.html"
277		} else {
278			host := u.Host
279			p := strings.TrimPrefix(u.Path, "/")
280			if host == "app" && p == "" {
281				rel = "index.html"
282			} else if host == "app" {
283				rel = p
284			} else if host == "homepage" && p != "" {
285				// dumb://homepage/<asset>
286				rel = p
287			} else if p != "" {
288				rel = p
289			} else {
290				rel = "index.html"
291			}
292		}
293		// Special-case homepage favicon: map .ico request to embedded SVG file
294		if (u.Host == "homepage" || u.Opaque == "homepage") && strings.EqualFold(rel, "favicon.ico") {
295			log.Printf("[scheme] asset: rel=%s (host=%s path=%s) → mapping to favicon.svg", rel, u.Host, u.Path)
296			data, rerr := assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", "favicon.svg")))
297			if rerr == nil {
298				return "image/svg+xml", data, true
299			}
300		}
301
302		log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path)
303		data, rerr := assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel)))
304		if rerr != nil {
305			log.Printf("[scheme] not found: %s", rel)
306			return "", nil, false
307		}
308		// Determine mime type
309		mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel)))
310		if mt == "" {
311			// Fallbacks
312			switch strings.ToLower(filepath.Ext(rel)) {
313			case ".js":
314				mt = "application/javascript"
315			case ".css":
316				mt = "text/css"
317			case ".svg":
318				mt = "image/svg+xml"
319			case ".ico":
320				mt = "image/x-icon"
321			default:
322				mt = "text/html; charset=utf-8"
323			}
324		}
325		return mt, data, true
326	})
327
328	// Create WebKit view
329	log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
330	dataDir, _ := config.GetDataDir()
331	stateDir, _ := config.GetStateDir()
332	webkitData := filepath.Join(dataDir, "webkit")
333	webkitCache := filepath.Join(stateDir, "webkit-cache")
334	_ = os.MkdirAll(webkitData, 0o755)
335	_ = os.MkdirAll(webkitCache, 0o755)
336	view, err := webkit.NewWebView(&webkit.Config{
337		InitialURL:            "dumb://homepage",
338		ZoomDefault:           1.0,
339		EnableDeveloperExtras: true,
340		DataDir:               webkitData,
341		CacheDir:              webkitCache,
342		DefaultSansFont:       cfg.Appearance.SansFont,
343		DefaultSerifFont:      cfg.Appearance.SerifFont,
344		DefaultMonospaceFont:  cfg.Appearance.MonospaceFont,
345		DefaultFontSize:       cfg.Appearance.DefaultFontSize,
346		Rendering:             webkit.RenderingConfig{Mode: string(cfg.RenderingMode)},
347		Memory:                readMemoryConfigFromEnv(),
348	})
349	if err != nil {
350		log.Printf("Warning: failed to create WebView: %v", err)
351	} else {
352		browserService.AttachWebView(view)
353		// Use native window as title updater
354		if win := view.Window(); win != nil {
355			browserService.SetWindowTitleUpdater(win)
356		}
357		// Persist page titles to DB when they change
358		view.RegisterTitleChangedHandler(func(title string) {
359			ctx := context.Background()
360			url := view.GetCurrentURL()
361			if url != "" && title != "" {
362				if err := browserService.UpdatePageTitle(ctx, url, title); err != nil {
363					log.Printf("Warning: failed to update page title: %v", err)
364				}
365			}
366		})
367		// Track current URL changes and apply saved zoom
368		view.RegisterURIChangedHandler(func(u string) {
369			currentURL = u
370			if u == "" {
371				return
372			}
373			ctx := context.Background()
374			if z, err := browserService.GetZoomLevel(ctx, u); err == nil {
375				_ = view.SetZoom(z)
376				key := services.ZoomKeyForLog(u)
377				log.Printf("[zoom] loaded %.2f for %s", z, key)
378			}
379		})
380		// Persist zoom changes per-domain
381		view.RegisterZoomChangedHandler(func(level float64) {
382			url := view.GetCurrentURL()
383			if url == "" {
384				return
385			}
386			ctx := context.Background()
387			if err := browserService.SetZoomLevel(ctx, url, level); err != nil {
388				log.Printf("[zoom] failed to save level %.2f for %s: %v", level, url, err)
389				return
390			}
391			key := services.ZoomKeyForLog(url)
392			log.Printf("[zoom] saved %.2f for %s", level, key)
393		})
394		// Bridge UCM messages: navigate + history queries
395		view.RegisterScriptMessageHandler(func(payload string) {
396			type msg struct {
397				Type  string `json:"type"`
398				URL   string `json:"url"`
399				Q     string `json:"q"`
400				Limit int    `json:"limit"`
401				Value string `json:"value"`
402				// Wails fetch bridge
403				ID      string          `json:"id"`
404				Payload json.RawMessage `json:"payload"`
405			}
406			var m msg
407			if err := json.Unmarshal([]byte(payload), &m); err != nil {
408				return
409			}
410			switch m.Type {
411			case "navigate":
412				ctx := context.Background()
413				res, err := parserService.ParseInput(ctx, m.URL)
414				if err == nil {
415					currentURL = res.URL
416					browserService.Navigate(ctx, currentURL)
417					_ = view.LoadURL(currentURL)
418					if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil {
419						_ = view.SetZoom(z)
420					}
421				}
422			case "query":
423				ctx := context.Background()
424				lim := m.Limit
425				if lim <= 0 || lim > 25 {
426					lim = 10
427				}
428				entries, err := browserService.SearchHistory(ctx, m.Q, lim)
429				if err != nil {
430					return
431				}
432				// Map to lightweight items
433				type item struct {
434					URL     string `json:"url"`
435					Favicon string `json:"favicon"`
436				}
437				buildFavicon := func(raw string) string {
438					u, err := parserService.ParseInput(ctx, raw)
439					if err != nil || u.URL == "" {
440						return ""
441					}
442					parsed, perr := neturl.Parse(u.URL)
443					if perr != nil || parsed.Host == "" {
444						return ""
445					}
446					scheme := parsed.Scheme
447					if scheme == "" {
448						scheme = "https"
449					}
450					return scheme + "://" + parsed.Host + "/favicon.ico"
451				}
452				items := make([]item, 0, len(entries))
453				for _, e := range entries {
454					items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)})
455				}
456				b, _ := json.Marshal(items)
457				// Update suggestions in page
458				_ = view.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")")
459			case "wails":
460				// Handle Wails runtime fetch bridge calls for homepage
461				// Payload contains { methodID, methodName?, args }
462				var p struct {
463					MethodID   uint32          `json:"methodID"`
464					MethodName string          `json:"methodName"`
465					Args       json.RawMessage `json:"args"`
466				}
467				if err := json.Unmarshal(m.Payload, &p); err != nil {
468					return
469				}
470				// Only implement the IDs we need
471				switch p.MethodID {
472				case 3708519028: // BrowserService.GetRecentHistory(limit)
473					var args []interface{}
474					_ = json.Unmarshal(p.Args, &args)
475					limit := 50
476					if len(args) > 0 {
477						if f, ok := args[0].(float64); ok {
478							limit = int(f)
479						}
480					}
481					ctx := context.Background()
482					entries, err := browserService.GetRecentHistory(ctx, limit)
483					if err != nil {
484						return
485					}
486					resp, _ := json.Marshal(entries)
487					_ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")")
488				case 4078533762: // BrowserService.GetSearchShortcuts()
489					ctx := context.Background()
490					shortcuts, err := browserService.GetSearchShortcuts(ctx)
491					if err != nil {
492						return
493					}
494					resp, _ := json.Marshal(shortcuts)
495					_ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")")
496				default:
497					// Return empty JSON to avoid breaking UI
498					_ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', '{}')")
499				}
500			case "theme":
501				if m.Value != "" {
502					log.Printf("[theme] page reported color-scheme: %s", m.Value)
503				}
504			}
505		})
506		// Apply initial zoom setting and log it before showing the window
507		{
508			ctx := context.Background()
509			u := view.GetCurrentURL()
510			if u == "" {
511				u = "dumb://homepage"
512			}
513			if z, zerr := browserService.GetZoomLevel(ctx, u); zerr == nil {
514				if err := view.SetZoom(z); err != nil {
515					log.Printf("Warning: failed to set initial zoom: %v", err)
516				} else {
517					key := services.ZoomKeyForLog(u)
518					log.Printf("[zoom] loaded %.2f for %s", z, key)
519				}
520			}
521		}
522
523		log.Printf("Showing WebView window…")
524		if err := view.Show(); err != nil {
525			log.Printf("Warning: failed to show WebView: %v", err)
526		} else {
527			if !webkit.IsNativeAvailable() {
528				log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
529			}
530		}
531	}
532
533	// Parse browse argument if present
534	if len(os.Args) >= 3 && os.Args[1] == "browse" {
535		log.Printf("Browse command detected: %s", os.Args[2])
536		ctx := context.Background()
537		result, err := parserService.ParseInput(ctx, os.Args[2])
538		if err == nil {
539			currentURL = result.URL
540			log.Printf("Parsed input → URL: %s", currentURL)
541			browserService.Navigate(ctx, currentURL)
542			if view != nil {
543				log.Printf("Loading URL in WebView: %s", currentURL)
544				if err := view.LoadURL(currentURL); err != nil {
545					log.Printf("Warning: failed to load URL: %v", err)
546				}
547				if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil {
548					if err := view.SetZoom(z); err != nil {
549						log.Printf("Warning: failed to set zoom: %v", err)
550					} else {
551						key := services.ZoomKeyForLog(currentURL)
552						log.Printf("[zoom] loaded %.2f for %s", z, key)
553					}
554				}
555				// No JS injection; native accelerators handle zoom/devtools.
556			}
557		}
558	}
559
560	// Register basic keyboard shortcuts on the native view to preserve behavior
561	if view != nil {
562		// DevTools
563		_ = view.RegisterKeyboardShortcut("F12", func() { log.Printf("Shortcut: F12 (devtools)"); _ = view.ShowDevTools() })
564		// Omnibox (Ctrl+L): rely on injected script listener
565		_ = view.RegisterKeyboardShortcut("cmdorctrl+l", func() {
566			log.Printf("Shortcut: Omnibox toggle")
567			_ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()")
568		})
569		// Copy URL (Ctrl+Shift+C)
570		_ = view.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() {
571			log.Printf("Shortcut: Copy URL")
572			currentURL := view.GetCurrentURL()
573			if currentURL == "" {
574				log.Printf("No URL to copy")
575				_ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`)
576				return
577			}
578			
579			if err := clipboard.CopyToClipboard(currentURL); err != nil {
580				log.Printf("Failed to copy URL to clipboard: %v", err)
581				_ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`)
582			} else {
583				log.Printf("URL copied to clipboard: %s", currentURL)
584				_ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("URL copied to clipboard", 2000)`)
585			}
586		})
587		// Zoom handled natively in webkit package (built-in shortcuts)
588	}
589
590	// Set up signal handling for graceful shutdown
591	sigChan := make(chan os.Signal, 1)
592	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
593
594	// Enter GTK main loop only when native backend is available.
595	if webkit.IsNativeAvailable() {
596		// Handle signals in a goroutine to quit the main loop
597		go func() {
598			sig := <-sigChan
599			log.Printf("Received signal %v - shutting down gracefully", sig)
600			webkit.QuitMainLoop()
601		}()
602
603		log.Printf("Entering GTK main loop…")
604		webkit.RunMainLoop()
605		log.Printf("GTK main loop exited")
606	} else {
607		log.Printf("Not entering GUI loop (non-CGO build)")
608		// In non-CGO mode, just wait for signals
609		sig := <-sigChan
610		log.Printf("Received signal %v - exiting", sig)
611	}
612}
613
614// readMemoryConfigFromEnv reads optional memory optimization knobs from environment variables.
615// Defaults favor performance; only applied when env vars are set.
616func readMemoryConfigFromEnv() webkit.MemoryConfig {
617	var mc webkit.MemoryConfig
618
619	// Cache model (document_viewer | web_browser | primary_web_browser)
620	if v := os.Getenv("DUMBER_CACHE_MODEL"); v != "" {
621		switch strings.ToLower(v) {
622		case "document_viewer", "documentviewer", "doc":
623			mc.CacheModel = webkit.CacheModelDocumentViewer
624		case "primary_web_browser", "primary", "primarywebbrowser":
625			mc.CacheModel = webkit.CacheModelPrimaryWebBrowser
626		default:
627			mc.CacheModel = webkit.CacheModelWebBrowser
628		}
629	}
630
631	// Page cache toggle (default true, only override if provided)
632	if v := os.Getenv("DUMBER_ENABLE_PAGE_CACHE"); v != "" {
633		mc.EnablePageCache = v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
634	} else {
635		mc.EnablePageCache = true
636	}
637
638	// Memory limit (MB)
639	if v := os.Getenv("DUMBER_MEMORY_LIMIT_MB"); v != "" {
640		if n, err := strconv.Atoi(v); err == nil && n >= 0 {
641			mc.MemoryLimitMB = n
642		}
643	}
644
645	// Thresholds
646	if v := os.Getenv("DUMBER_MEMORY_CONSERVATIVE"); v != "" {
647		if f, err := strconv.ParseFloat(v, 64); err == nil {
648			mc.ConservativeThreshold = f
649		}
650	}
651	if v := os.Getenv("DUMBER_MEMORY_STRICT"); v != "" {
652		if f, err := strconv.ParseFloat(v, 64); err == nil {
653			mc.StrictThreshold = f
654		}
655	}
656	if v := os.Getenv("DUMBER_MEMORY_KILL"); v != "" {
657		if f, err := strconv.ParseFloat(v, 64); err == nil {
658			mc.KillThreshold = f
659		}
660	}
661	if v := os.Getenv("DUMBER_MEMORY_POLL_INTERVAL"); v != "" {
662		if f, err := strconv.ParseFloat(v, 64); err == nil {
663			mc.PollIntervalSeconds = f
664		}
665	}
666
667	// Periodic GC (seconds)
668	if v := os.Getenv("DUMBER_GC_INTERVAL"); v != "" {
669		if n, err := strconv.Atoi(v); err == nil && n >= 0 {
670			mc.EnableGCInterval = n
671		}
672	}
673
674	// Recycle after N page loads
675	if v := os.Getenv("DUMBER_RECYCLE_THRESHOLD"); v != "" {
676		if n, err := strconv.Atoi(v); err == nil && n >= 0 {
677			mc.ProcessRecycleThreshold = n
678		}
679	}
680
681	// Enable memory monitoring logs
682	if v := os.Getenv("DUMBER_ENABLE_MEMORY_MONITORING"); v != "" {
683		mc.EnableMemoryMonitoring = v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
684	}
685
686	return mc
687}
688
689// detectAndSetKeyboardLocale attempts to determine the current keyboard layout
690// and hints it to the webkit input layer for accelerator compatibility.
691func detectAndSetKeyboardLocale() {
692	// 0) Explicit override
693	locale := os.Getenv("DUMBER_KEYBOARD_LOCALE")
694	if locale == "" {
695		locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix
696	}
697	// 1) Environment
698	if locale == "" {
699		locale = os.Getenv("LC_ALL")
700	}
701	if locale == "" {
702		locale = os.Getenv("LANG")
703	}
704	if locale == "" {
705		locale = os.Getenv("LC_CTYPE")
706	}
707	// Trim variants
708	if i := strings.IndexByte(locale, '.'); i > 0 {
709		locale = locale[:i]
710	}
711	if i := strings.IndexByte(locale, '@'); i > 0 {
712		locale = locale[:i]
713	}
714	if i := strings.IndexByte(locale, '_'); i > 0 {
715		locale = locale[:i]
716	}
717
718	// 2) Try XKB env override
719	if locale == "" {
720		locale = os.Getenv("XKB_DEFAULT_LAYOUT")
721	}
722
723	// 3) Best-effort probe of setxkbmap/localectl (non-fatal)
724	if locale == "" {
725		if out, err := exec.Command("localectl", "status").Output(); err == nil {
726			s := string(out)
727			for _, line := range strings.Split(s, "\n") {
728				line = strings.TrimSpace(line)
729				if strings.HasPrefix(strings.ToLower(line), "x11 layout:") || strings.HasPrefix(strings.ToLower(line), "keyboard layout:") {
730					parts := strings.SplitN(line, ":", 2)
731					if len(parts) == 2 {
732						cand := strings.TrimSpace(parts[1])
733						if cand != "" {
734							locale = cand
735						}
736					}
737					break
738				}
739			}
740		}
741	}
742	if locale == "" {
743		if out, err := exec.Command("setxkbmap", "-query").Output(); err == nil {
744			s := string(out)
745			for _, line := range strings.Split(s, "\n") {
746				line = strings.TrimSpace(line)
747				if strings.HasPrefix(strings.ToLower(line), "layout:") {
748					parts := strings.SplitN(line, ":", 2)
749					if len(parts) == 2 {
750						cand := strings.TrimSpace(parts[1])
751						if cand != "" {
752							locale = cand
753						}
754					}
755					break
756				}
757			}
758		}
759	}
760
761	if locale == "" {
762		locale = "en"
763	}
764	// No layout-specific remaps; log for diagnostics only
765	log.Printf("[locale] keyboard locale detected: %s", locale)
766}
767
768// getHomepageFaviconSVG returns the SVG used as a favicon for the homepage
769// when WebKit requests dumb://homepage/favicon.ico.
770// getHomepageFaviconSVG was inlined previously; now favicon.svg is emitted at build time.

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