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