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.