v0.3.0 d226234

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	"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"
23)
24
25// Build information set via ldflags
26var (
27	version   = "dev"
28	commit    = "none"
29	buildDate = "unknown"
30)
31
32func main() {
33	// Check if we should run the CLI mode
34	if shouldRunCLI() {
35		runCLI()
36		return
37	}
38
39	// Otherwise run the GUI browser
40	runBrowser()
41}
42
43// shouldRunCLI determines if we should run in CLI mode based on arguments
44func shouldRunCLI() bool {
45	// No args = GUI landing page mode
46	if len(os.Args) <= 1 {
47		return false
48	}
49
50	// Check for GUI-specific flags
51	for _, arg := range os.Args[1:] {
52		if arg == "--gui" || arg == "-g" {
53			return false // Explicit GUI mode
54		}
55	}
56
57	// Check for browse command - this should open GUI in direct navigation mode
58	if len(os.Args) >= 2 && os.Args[1] == "browse" {
59		return false // Browse command uses GUI mode but navigates directly
60	}
61
62	// Any other arguments mean CLI mode
63	return true
64}
65
66// runCLI executes the CLI functionality using the existing CLI package
67func runCLI() {
68	// Initialize configuration system
69	if err := config.Init(); err != nil {
70		fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err)
71		os.Exit(1)
72	}
73
74	// Start configuration watching for live reload
75	if err := config.Watch(); err != nil {
76		// Don't exit on watch error, just warn
77		fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err)
78	}
79
80	rootCmd := cli.NewRootCmd(version, commit, buildDate)
81
82	// Handle dmenu flag at the root level for direct integration
83	if len(os.Args) > 1 && os.Args[1] == "--dmenu" {
84		// Create a temporary CLI instance to handle dmenu mode
85		cliInstance, err := cli.NewCLI()
86		if err != nil {
87			fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err)
88			os.Exit(1)
89		}
90		defer func() {
91			if closeErr := cliInstance.Close(); closeErr != nil {
92				fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
93			}
94		}()
95
96		// Generate dmenu options
97		dmenuCmd := cli.NewDmenuCmd()
98		if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil {
99			fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err)
100			os.Exit(1)
101		}
102		return
103	}
104
105	// Execute the root command
106	if err := rootCmd.Execute(); err != nil {
107		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
108		os.Exit(1)
109	}
110}
111
112//go:embed frontend/dist
113var assets embed.FS
114
115// runBrowser launches a minimal native WebKit window and integrates services.
116func runBrowser() {
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		// Special-case homepage favicon: map .ico request to embedded SVG file
291		if (u.Host == "homepage" || u.Opaque == "homepage") && strings.EqualFold(rel, "favicon.ico") {
292			log.Printf("[scheme] asset: rel=%s (host=%s path=%s) → mapping to favicon.svg", rel, u.Host, u.Path)
293			data, rerr := assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", "favicon.svg")))
294			if rerr == nil {
295				return "image/svg+xml", data, true
296			}
297		}
298
299		log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path)
300		data, rerr := assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel)))
301		if rerr != nil {
302			log.Printf("[scheme] not found: %s", rel)
303			return "", nil, false
304		}
305		// Determine mime type
306		mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel)))
307		if mt == "" {
308			// Fallbacks
309			switch strings.ToLower(filepath.Ext(rel)) {
310			case ".js":
311				mt = "application/javascript"
312			case ".css":
313				mt = "text/css"
314			case ".svg":
315				mt = "image/svg+xml"
316			case ".ico":
317				mt = "image/x-icon"
318			default:
319				mt = "text/html; charset=utf-8"
320			}
321		}
322		return mt, data, true
323	})
324
325	// Create WebKit view
326	log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
327	dataDir, _ := config.GetDataDir()
328	stateDir, _ := config.GetStateDir()
329	webkitData := filepath.Join(dataDir, "webkit")
330	webkitCache := filepath.Join(stateDir, "webkit-cache")
331	_ = os.MkdirAll(webkitData, 0o755)
332	_ = os.MkdirAll(webkitCache, 0o755)
333	view, err := webkit.NewWebView(&webkit.Config{
334		InitialURL:            "dumb://homepage",
335		ZoomDefault:           1.0,
336		EnableDeveloperExtras: true,
337		DataDir:               webkitData,
338		CacheDir:              webkitCache,
339		DefaultSansFont:       cfg.Appearance.SansFont,
340		DefaultSerifFont:      cfg.Appearance.SerifFont,
341		DefaultMonospaceFont:  cfg.Appearance.MonospaceFont,
342		DefaultFontSize:       cfg.Appearance.DefaultFontSize,
343		Rendering:             webkit.RenderingConfig{Mode: string(cfg.RenderingMode)},
344		Memory:                readMemoryConfigFromEnv(),
345	})
346	if err != nil {
347		log.Printf("Warning: failed to create WebView: %v", err)
348	} else {
349		browserService.AttachWebView(view)
350		// Use native window as title updater
351		if win := view.Window(); win != nil {
352			browserService.SetWindowTitleUpdater(win)
353		}
354		// Persist page titles to DB when they change
355		view.RegisterTitleChangedHandler(func(title string) {
356			ctx := context.Background()
357			url := view.GetCurrentURL()
358			if url != "" && title != "" {
359				if err := browserService.UpdatePageTitle(ctx, url, title); err != nil {
360					log.Printf("Warning: failed to update page title: %v", err)
361				}
362			}
363		})
364		// Track current URL changes and apply saved zoom
365		view.RegisterURIChangedHandler(func(u string) {
366			currentURL = u
367			if u == "" {
368				return
369			}
370			ctx := context.Background()
371			if z, err := browserService.GetZoomLevel(ctx, u); err == nil {
372				_ = view.SetZoom(z)
373				key := services.ZoomKeyForLog(u)
374				log.Printf("[zoom] loaded %.2f for %s", z, key)
375			}
376		})
377		// Persist zoom changes per-domain
378		view.RegisterZoomChangedHandler(func(level float64) {
379			url := view.GetCurrentURL()
380			if url == "" {
381				return
382			}
383			ctx := context.Background()
384			if err := browserService.SetZoomLevel(ctx, url, level); err != nil {
385				log.Printf("[zoom] failed to save level %.2f for %s: %v", level, url, err)
386				return
387			}
388			key := services.ZoomKeyForLog(url)
389			log.Printf("[zoom] saved %.2f for %s", level, key)
390		})
391		// Bridge UCM messages: navigate + history queries
392		view.RegisterScriptMessageHandler(func(payload string) {
393			type msg struct {
394				Type  string `json:"type"`
395				URL   string `json:"url"`
396				Q     string `json:"q"`
397				Limit int    `json:"limit"`
398				Value string `json:"value"`
399				// Wails fetch bridge
400				ID      string          `json:"id"`
401				Payload json.RawMessage `json:"payload"`
402			}
403			var m msg
404			if err := json.Unmarshal([]byte(payload), &m); err != nil {
405				return
406			}
407			switch m.Type {
408			case "navigate":
409				ctx := context.Background()
410				res, err := parserService.ParseInput(ctx, m.URL)
411				if err == nil {
412					currentURL = res.URL
413					browserService.Navigate(ctx, currentURL)
414					_ = view.LoadURL(currentURL)
415					if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil {
416						_ = view.SetZoom(z)
417					}
418				}
419			case "query":
420				ctx := context.Background()
421				lim := m.Limit
422				if lim <= 0 || lim > 25 {
423					lim = 10
424				}
425				entries, err := browserService.SearchHistory(ctx, m.Q, lim)
426				if err != nil {
427					return
428				}
429				// Map to lightweight items
430				type item struct {
431					URL     string `json:"url"`
432					Favicon string `json:"favicon"`
433				}
434				buildFavicon := func(raw string) string {
435					u, err := parserService.ParseInput(ctx, raw)
436					if err != nil || u.URL == "" {
437						return ""
438					}
439					parsed, perr := neturl.Parse(u.URL)
440					if perr != nil || parsed.Host == "" {
441						return ""
442					}
443					scheme := parsed.Scheme
444					if scheme == "" {
445						scheme = "https"
446					}
447					return scheme + "://" + parsed.Host + "/favicon.ico"
448				}
449				items := make([]item, 0, len(entries))
450				for _, e := range entries {
451					items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)})
452				}
453				b, _ := json.Marshal(items)
454				// Update suggestions in page
455				_ = view.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")")
456			case "wails":
457				// Handle Wails runtime fetch bridge calls for homepage
458				// Payload contains { methodID, methodName?, args }
459				var p struct {
460					MethodID   uint32          `json:"methodID"`
461					MethodName string          `json:"methodName"`
462					Args       json.RawMessage `json:"args"`
463				}
464				if err := json.Unmarshal(m.Payload, &p); err != nil {
465					return
466				}
467				// Only implement the IDs we need
468				switch p.MethodID {
469				case 3708519028: // BrowserService.GetRecentHistory(limit)
470					var args []interface{}
471					_ = json.Unmarshal(p.Args, &args)
472					limit := 50
473					if len(args) > 0 {
474						if f, ok := args[0].(float64); ok {
475							limit = int(f)
476						}
477					}
478					ctx := context.Background()
479					entries, err := browserService.GetRecentHistory(ctx, limit)
480					if err != nil {
481						return
482					}
483					resp, _ := json.Marshal(entries)
484					_ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")")
485				case 4078533762: // BrowserService.GetSearchShortcuts()
486					ctx := context.Background()
487					shortcuts, err := browserService.GetSearchShortcuts(ctx)
488					if err != nil {
489						return
490					}
491					resp, _ := json.Marshal(shortcuts)
492					_ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")")
493				default:
494					// Return empty JSON to avoid breaking UI
495					_ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', '{}')")
496				}
497			case "theme":
498				if m.Value != "" {
499					log.Printf("[theme] page reported color-scheme: %s", m.Value)
500				}
501			}
502		})
503		// Apply initial zoom setting and log it before showing the window
504		{
505			ctx := context.Background()
506			u := view.GetCurrentURL()
507			if u == "" {
508				u = "dumb://homepage"
509			}
510			if z, zerr := browserService.GetZoomLevel(ctx, u); zerr == nil {
511				if err := view.SetZoom(z); err != nil {
512					log.Printf("Warning: failed to set initial zoom: %v", err)
513				} else {
514					key := services.ZoomKeyForLog(u)
515					log.Printf("[zoom] loaded %.2f for %s", z, key)
516				}
517			}
518		}
519
520		log.Printf("Showing WebView window…")
521		if err := view.Show(); err != nil {
522			log.Printf("Warning: failed to show WebView: %v", err)
523		} else {
524			if !webkit.IsNativeAvailable() {
525				log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
526			}
527		}
528	}
529
530	// Parse browse argument if present
531	if len(os.Args) >= 3 && os.Args[1] == "browse" {
532		log.Printf("Browse command detected: %s", os.Args[2])
533		ctx := context.Background()
534		result, err := parserService.ParseInput(ctx, os.Args[2])
535		if err == nil {
536			currentURL = result.URL
537			log.Printf("Parsed input → URL: %s", currentURL)
538			browserService.Navigate(ctx, currentURL)
539			if view != nil {
540				log.Printf("Loading URL in WebView: %s", currentURL)
541				if err := view.LoadURL(currentURL); err != nil {
542					log.Printf("Warning: failed to load URL: %v", err)
543				}
544				if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil {
545					if err := view.SetZoom(z); err != nil {
546						log.Printf("Warning: failed to set zoom: %v", err)
547					} else {
548						key := services.ZoomKeyForLog(currentURL)
549						log.Printf("[zoom] loaded %.2f for %s", z, key)
550					}
551				}
552				// No JS injection; native accelerators handle zoom/devtools.
553			}
554		}
555	}
556
557	// Register basic keyboard shortcuts on the native view to preserve behavior
558	if view != nil {
559		// DevTools
560		_ = view.RegisterKeyboardShortcut("F12", func() { log.Printf("Shortcut: F12 (devtools)"); _ = view.ShowDevTools() })
561		// Omnibox (Ctrl+L): rely on injected script listener
562		_ = view.RegisterKeyboardShortcut("cmdorctrl+l", func() {
563			log.Printf("Shortcut: Omnibox toggle")
564			_ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()")
565		})
566		// Zoom handled natively in webkit package (built-in shortcuts)
567	}
568
569	// Enter GTK main loop only when native backend is available.
570	if webkit.IsNativeAvailable() {
571		log.Printf("Entering GTK main loop…")
572		webkit.RunMainLoop()
573		log.Printf("GTK main loop exited")
574	} else {
575		log.Printf("Not entering GUI loop (non-CGO build)")
576	}
577}
578
579// readMemoryConfigFromEnv reads optional memory optimization knobs from environment variables.
580// Defaults favor performance; only applied when env vars are set.
581func readMemoryConfigFromEnv() webkit.MemoryConfig {
582	var mc webkit.MemoryConfig
583
584	// Cache model (document_viewer | web_browser | primary_web_browser)
585	if v := os.Getenv("DUMBER_CACHE_MODEL"); v != "" {
586		switch strings.ToLower(v) {
587		case "document_viewer", "documentviewer", "doc":
588			mc.CacheModel = webkit.CacheModelDocumentViewer
589		case "primary_web_browser", "primary", "primarywebbrowser":
590			mc.CacheModel = webkit.CacheModelPrimaryWebBrowser
591		default:
592			mc.CacheModel = webkit.CacheModelWebBrowser
593		}
594	}
595
596	// Page cache toggle (default true, only override if provided)
597	if v := os.Getenv("DUMBER_ENABLE_PAGE_CACHE"); v != "" {
598		mc.EnablePageCache = v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
599	} else {
600		mc.EnablePageCache = true
601	}
602
603	// Memory limit (MB)
604	if v := os.Getenv("DUMBER_MEMORY_LIMIT_MB"); v != "" {
605		if n, err := strconv.Atoi(v); err == nil && n >= 0 {
606			mc.MemoryLimitMB = n
607		}
608	}
609
610	// Thresholds
611	if v := os.Getenv("DUMBER_MEMORY_CONSERVATIVE"); v != "" {
612		if f, err := strconv.ParseFloat(v, 64); err == nil {
613			mc.ConservativeThreshold = f
614		}
615	}
616	if v := os.Getenv("DUMBER_MEMORY_STRICT"); v != "" {
617		if f, err := strconv.ParseFloat(v, 64); err == nil {
618			mc.StrictThreshold = f
619		}
620	}
621	if v := os.Getenv("DUMBER_MEMORY_KILL"); v != "" {
622		if f, err := strconv.ParseFloat(v, 64); err == nil {
623			mc.KillThreshold = f
624		}
625	}
626	if v := os.Getenv("DUMBER_MEMORY_POLL_INTERVAL"); v != "" {
627		if f, err := strconv.ParseFloat(v, 64); err == nil {
628			mc.PollIntervalSeconds = f
629		}
630	}
631
632	// Periodic GC (seconds)
633	if v := os.Getenv("DUMBER_GC_INTERVAL"); v != "" {
634		if n, err := strconv.Atoi(v); err == nil && n >= 0 {
635			mc.EnableGCInterval = n
636		}
637	}
638
639	// Recycle after N page loads
640	if v := os.Getenv("DUMBER_RECYCLE_THRESHOLD"); v != "" {
641		if n, err := strconv.Atoi(v); err == nil && n >= 0 {
642			mc.ProcessRecycleThreshold = n
643		}
644	}
645
646	// Enable memory monitoring logs
647	if v := os.Getenv("DUMBER_ENABLE_MEMORY_MONITORING"); v != "" {
648		mc.EnableMemoryMonitoring = v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
649	}
650
651	return mc
652}
653
654// detectAndSetKeyboardLocale attempts to determine the current keyboard layout
655// and hints it to the webkit input layer for accelerator compatibility.
656func detectAndSetKeyboardLocale() {
657	// 0) Explicit override
658	locale := os.Getenv("DUMBER_KEYBOARD_LOCALE")
659	if locale == "" {
660		locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix
661	}
662	// 1) Environment
663	if locale == "" {
664		locale = os.Getenv("LC_ALL")
665	}
666	if locale == "" {
667		locale = os.Getenv("LANG")
668	}
669	if locale == "" {
670		locale = os.Getenv("LC_CTYPE")
671	}
672	// Trim variants
673	if i := strings.IndexByte(locale, '.'); i > 0 {
674		locale = locale[:i]
675	}
676	if i := strings.IndexByte(locale, '@'); i > 0 {
677		locale = locale[:i]
678	}
679	if i := strings.IndexByte(locale, '_'); i > 0 {
680		locale = locale[:i]
681	}
682
683	// 2) Try XKB env override
684	if locale == "" {
685		locale = os.Getenv("XKB_DEFAULT_LAYOUT")
686	}
687
688	// 3) Best-effort probe of setxkbmap/localectl (non-fatal)
689	if locale == "" {
690		if out, err := exec.Command("localectl", "status").Output(); err == nil {
691			s := string(out)
692			for _, line := range strings.Split(s, "\n") {
693				line = strings.TrimSpace(line)
694				if strings.HasPrefix(strings.ToLower(line), "x11 layout:") || strings.HasPrefix(strings.ToLower(line), "keyboard layout:") {
695					parts := strings.SplitN(line, ":", 2)
696					if len(parts) == 2 {
697						cand := strings.TrimSpace(parts[1])
698						if cand != "" {
699							locale = cand
700						}
701					}
702					break
703				}
704			}
705		}
706	}
707	if locale == "" {
708		if out, err := exec.Command("setxkbmap", "-query").Output(); err == nil {
709			s := string(out)
710			for _, line := range strings.Split(s, "\n") {
711				line = strings.TrimSpace(line)
712				if strings.HasPrefix(strings.ToLower(line), "layout:") {
713					parts := strings.SplitN(line, ":", 2)
714					if len(parts) == 2 {
715						cand := strings.TrimSpace(parts[1])
716						if cand != "" {
717							locale = cand
718						}
719					}
720					break
721				}
722			}
723		}
724	}
725
726	if locale == "" {
727		locale = "en"
728	}
729	// No layout-specific remaps; log for diagnostics only
730	log.Printf("[locale] keyboard locale detected: %s", locale)
731}
732
733// getHomepageFaviconSVG returns the SVG used as a favicon for the homepage
734// when WebKit requests dumb://homepage/favicon.ico.
735// 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