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 // Apply codec preferences configuration
196 applyCodecConfiguration(cfg.CodecPreferences)
197
198 // Initialize logging system early
199 if err := logging.Init(
200 cfg.Logging.LogDir,
201 cfg.Logging.Level,
202 cfg.Logging.Format,
203 cfg.Logging.EnableFileLog,
204 cfg.Logging.MaxSize,
205 cfg.Logging.MaxBackups,
206 cfg.Logging.MaxAge,
207 cfg.Logging.Compress,
208 ); err != nil {
209 log.Printf("Warning: failed to initialize logging: %v", err)
210 }
211
212 // Output capture disabled by default to avoid interference with normal operations
213 // Can be enabled via config if needed for debugging
214 var outputCapture *logging.OutputCapture
215 if cfg.Logging.CaptureStdout || cfg.Logging.CaptureStderr {
216 log.Printf("Warning: stdout/stderr capture is experimental and may interfere with normal operations")
217 if logger := logging.GetLogger(); logger != nil {
218 outputCapture = logging.NewOutputCapture(logger)
219 if err := outputCapture.Start(); err != nil {
220 log.Printf("Warning: failed to start output capture: %v", err)
221 } else {
222 defer outputCapture.Stop()
223 }
224 }
225 }
226
227 // Initialize WebKit log capture if configured
228 if cfg.Logging.CaptureCOutput {
229 if err := webkit.InitWebKitLogCapture(); err != nil {
230 log.Printf("Warning: failed to initialize WebKit log capture: %v", err)
231 } else {
232 defer webkit.StopWebKitLogCapture()
233 }
234 }
235
236 log.Printf("Config initialized")
237
238 // Detect keyboard layout/locale and hint to webkit layer
239 detectAndSetKeyboardLocale()
240
241 // Initialize database
242 database, err := db.InitDB(cfg.Database.Path)
243 if err != nil {
244 log.Printf("Failed to initialize database: %v", err)
245 if webkit.IsNativeAvailable() {
246 runtime.UnlockOSThread()
247 }
248 os.Exit(1)
249 }
250 defer func() {
251 if closeErr := database.Close(); closeErr != nil {
252 log.Printf("Warning: failed to close database: %v", closeErr)
253 }
254 }()
255 queries := db.New(database)
256 log.Printf("Database opened at %s", cfg.Database.Path)
257
258 // Initialize services
259 parserService := services.NewParserService(cfg, queries)
260 browserService := services.NewBrowserService(cfg, queries)
261 // JS controls injection is disabled; using native shortcuts via GTK.
262
263 // Track current URL for zoom persistence
264 // Initialize to the initial homepage so shortcuts work immediately
265 var currentURL string = "dumb://homepage"
266
267 // Track last zoom domain to avoid showing toaster on every navigation within same domain
268 var lastZoomDomain string
269
270 // Flag to prevent showing toast when programmatically setting zoom (not user-initiated)
271 var programmaticZoomChange bool
272
273 // Register custom scheme resolver for dumb://
274 webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) {
275 log.Printf("[scheme] request: %s", uri)
276 // Known forms:
277 // - dumb://homepage or dumb:homepage → index.html
278 // - dumb://app/index.html, dumb://app/<path> → serve from frontend/dist/<path>
279 // - dumb://<anything> without path → index.html
280 u, err := neturl.Parse(uri)
281 if err != nil || u.Scheme != "dumb" {
282 return "", nil, false
283 }
284 // API routes
285 if u.Host == "api" || strings.HasPrefix(u.Opaque, "api") || strings.HasPrefix(u.Path, "/api") {
286 // Normalize path for opaque or hierarchical forms
287 path := u.Path
288 if path == "" && u.Opaque != "" {
289 // e.g., dumb:api/config or dumb:api/history/recent
290 parts := strings.SplitN(u.Opaque, ":", colonSplitParts)
291 if len(parts) == colonSplitParts {
292 path = "/" + parts[1]
293 }
294 }
295 if strings.HasPrefix(path, "/api/") {
296 path = strings.TrimPrefix(path, "/api")
297 }
298 if path == "/api" {
299 path = "/"
300 }
301 switch {
302 case strings.HasPrefix(path, "/rendering/status"):
303 log.Printf("[api] GET /rendering/status")
304 // Report configured rendering mode; runtime GPU state depends on WebKit internals
305 resp := struct {
306 Mode string `json:"mode"`
307 }{Mode: string(cfg.RenderingMode)}
308 b, _ := json.Marshal(resp)
309 return contentTypeJSON, b, true
310 case strings.HasPrefix(path, "/config"):
311 log.Printf("[api] GET /config")
312 // Build config info
313 cfgPath, _ := config.GetConfigFile()
314 info := struct {
315 ConfigPath string `json:"config_path"`
316 DatabasePath string `json:"database_path"`
317 SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
318 Appearance config.AppearanceConfig `json:"appearance"`
319 }{
320 ConfigPath: cfgPath,
321 DatabasePath: cfg.Database.Path,
322 SearchShortcuts: cfg.SearchShortcuts,
323 Appearance: cfg.Appearance,
324 }
325 b, _ := json.Marshal(info)
326 return contentTypeJSON, b, true
327 case strings.HasPrefix(path, "/history/recent"):
328 log.Printf("[api] GET /history/recent%s", u.RawQuery)
329 // Parse limit
330 q := u.Query()
331 limit := 50
332 if l := q.Get("limit"); l != "" {
333 if n, err := strconv.Atoi(l); err == nil && n > 0 {
334 limit = n
335 }
336 }
337 ctx := context.Background()
338 entries, err := browserService.GetRecentHistory(ctx, limit)
339 if err != nil {
340 return contentTypeJSON, []byte("[]"), true
341 }
342 b, _ := json.Marshal(entries)
343 return contentTypeJSON, b, true
344 case strings.HasPrefix(path, "/history/search"):
345 log.Printf("[api] GET /history/search%s", u.RawQuery)
346 q := u.Query()
347 query := q.Get("q")
348 limit := 50
349 if l := q.Get("limit"); l != "" {
350 if n, err := strconv.Atoi(l); err == nil && n > 0 {
351 limit = n
352 }
353 }
354 ctx := context.Background()
355 entries, err := browserService.SearchHistory(ctx, query, limit)
356 if err != nil {
357 return contentTypeJSON, []byte("[]"), true
358 }
359 b, _ := json.Marshal(entries)
360 return contentTypeJSON, b, true
361 case strings.HasPrefix(path, "/history/stats"):
362 log.Printf("[api] GET /history/stats")
363 ctx := context.Background()
364 stats, err := browserService.GetHistoryStats(ctx)
365 if err != nil {
366 return contentTypeJSON, []byte("{}"), true
367 }
368 b, _ := json.Marshal(stats)
369 return contentTypeJSON, b, true
370 default:
371 return contentTypeJSON, []byte("{}"), true
372 }
373 }
374 // Resolve target path inside embed FS
375 var rel string
376 if u.Opaque == homepagePath || (u.Host == homepagePath && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) {
377 rel = indexHTML
378 } else {
379 host := u.Host
380 p := strings.TrimPrefix(u.Path, "/")
381 switch {
382 case host == "app" && p == "":
383 rel = indexHTML
384 case host == "app":
385 rel = p
386 case host == homepagePath && p != "":
387 // dumb://homepage/<asset>
388 rel = p
389 case p != "":
390 rel = p
391 default:
392 rel = indexHTML
393 }
394 }
395 // Special-case homepage favicon: map .ico request to embedded SVG file
396 if (u.Host == homepagePath || u.Opaque == homepagePath) && strings.EqualFold(rel, "favicon.ico") {
397 log.Printf("[scheme] asset: rel=%s (host=%s path=%s) → mapping to favicon.svg", rel, u.Host, u.Path)
398 data, rerr := assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", "favicon.svg")))
399 if rerr == nil {
400 return "image/svg+xml", data, true
401 }
402 }
403
404 log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path)
405 data, rerr := assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel)))
406 if rerr != nil {
407 log.Printf("[scheme] not found: %s", rel)
408 return "", nil, false
409 }
410 // Determine mime type
411 mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel)))
412 if mt == "" {
413 // Fallbacks
414 switch strings.ToLower(filepath.Ext(rel)) {
415 case ".js":
416 mt = "application/javascript"
417 case ".css":
418 mt = "text/css"
419 case ".svg":
420 mt = "image/svg+xml"
421 case ".ico":
422 mt = "image/x-icon"
423 default:
424 mt = "text/html; charset=utf-8"
425 }
426 }
427 return mt, data, true
428 })
429
430 // Create WebKit view
431 log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
432 dataDir, _ := config.GetDataDir()
433 stateDir, _ := config.GetStateDir()
434 webkitData := filepath.Join(dataDir, "webkit")
435 webkitCache := filepath.Join(stateDir, "webkit-cache")
436 _ = os.MkdirAll(webkitData, dirPerm)
437 _ = os.MkdirAll(webkitCache, dirPerm)
438 view, err := webkit.NewWebView(&webkit.Config{
439 InitialURL: "dumb://homepage",
440 ZoomDefault: 1.0,
441 EnableDeveloperExtras: true,
442 DataDir: webkitData,
443 CacheDir: webkitCache,
444 DefaultSansFont: cfg.Appearance.SansFont,
445 DefaultSerifFont: cfg.Appearance.SerifFont,
446 DefaultMonospaceFont: cfg.Appearance.MonospaceFont,
447 DefaultFontSize: cfg.Appearance.DefaultFontSize,
448 Rendering: webkit.RenderingConfig{Mode: string(cfg.RenderingMode)},
449 VideoAcceleration: webkit.VideoAccelerationConfig{
450 EnableVAAPI: cfg.VideoAcceleration.EnableVAAPI,
451 AutoDetectGPU: cfg.VideoAcceleration.AutoDetectGPU,
452 VAAPIDriverName: cfg.VideoAcceleration.VAAPIDriverName,
453 EnableAllDrivers: cfg.VideoAcceleration.EnableAllDrivers,
454 LegacyVAAPI: cfg.VideoAcceleration.LegacyVAAPI,
455 },
456 Memory: readMemoryConfigFromEnv(),
457 CodecPreferences: webkit.CodecPreferencesConfig{
458 PreferredCodecs: strings.Split(cfg.CodecPreferences.PreferredCodecs, ","),
459 BlockedCodecs: buildBlockedCodecsList(cfg.CodecPreferences),
460 ForceAV1: cfg.CodecPreferences.ForceAV1,
461 CustomUserAgent: cfg.CodecPreferences.CustomUserAgent,
462 DisableTwitchCodecControl: cfg.CodecPreferences.DisableTwitchCodecControl,
463 },
464 })
465 if err != nil {
466 log.Printf("Warning: failed to create WebView: %v", err)
467 } else {
468 browserService.AttachWebView(view)
469 // Use native window as title updater
470 if win := view.Window(); win != nil {
471 browserService.SetWindowTitleUpdater(win)
472 }
473 // Persist page titles to DB when they change
474 view.RegisterTitleChangedHandler(func(title string) {
475 ctx := context.Background()
476 url := view.GetCurrentURL()
477 if url != "" && title != "" {
478 if err := browserService.UpdatePageTitle(ctx, url, title); err != nil {
479 log.Printf("Warning: failed to update page title: %v", err)
480 }
481 }
482 })
483 // Track current URL changes and apply saved zoom
484 view.RegisterURIChangedHandler(func(u string) {
485 currentURL = u
486 if u == "" {
487 return
488 }
489 ctx := context.Background()
490 currentDomain := services.ZoomKeyForLog(u)
491
492 if z, err := browserService.GetZoomLevel(ctx, u); err == nil {
493 programmaticZoomChange = true
494 _ = view.SetZoom(z)
495 programmaticZoomChange = false
496 log.Printf("[zoom] loaded %.2f for %s", z, currentDomain)
497
498 // Only show toast when entering a new domain (not on first load)
499 if currentDomain != lastZoomDomain && lastZoomDomain != "" {
500 js := fmt.Sprintf(`try {
501 if (typeof window.__dumber_showZoomToast === 'function') {
502 window.__dumber_showZoomToast(%f);
503 }
504 } catch(e) {
505 console.error('[dumber] Zoom toast error:', e);
506 }`, z)
507 if err := view.InjectScript(js); err != nil {
508 log.Printf("[zoom] failed to show domain change toast: %v", err)
509 }
510 }
511 lastZoomDomain = currentDomain
512 }
513 })
514 // Persist zoom changes per-domain
515 view.RegisterZoomChangedHandler(func(level float64) {
516 url := view.GetCurrentURL()
517 if url == "" {
518 return
519 }
520 ctx := context.Background()
521 if err := browserService.SetZoomLevel(ctx, url, level); err != nil {
522 log.Printf("[zoom] failed to save level %.2f for %s: %v", level, url, err)
523 return
524 }
525 key := services.ZoomKeyForLog(url)
526 log.Printf("[zoom] saved %.2f for %s", level, key)
527
528 // Only show toast for user-initiated zoom changes, not programmatic ones
529 if !programmaticZoomChange {
530 js := fmt.Sprintf(`try {
531 if (typeof window.__dumber_showZoomToast === 'function') {
532 window.__dumber_showZoomToast(%f);
533 } else {
534 console.warn('[dumber] Zoom toast function not available yet');
535 }
536 } catch(e) {
537 console.error('[dumber] Zoom toast error:', e);
538 }`, level)
539 if err := view.InjectScript(js); err != nil {
540 log.Printf("[zoom] failed to show toast: %v", err)
541 }
542 }
543 })
544 // Bridge UCM messages: navigate + history queries
545 view.RegisterScriptMessageHandler(func(payload string) {
546 type msg struct {
547 Type string `json:"type"`
548 URL string `json:"url"`
549 Q string `json:"q"`
550 Limit int `json:"limit"`
551 Value string `json:"value"`
552 // Wails fetch bridge
553 ID string `json:"id"`
554 Payload json.RawMessage `json:"payload"`
555 }
556 var m msg
557 if err := json.Unmarshal([]byte(payload), &m); err != nil {
558 return
559 }
560 switch m.Type {
561 case "navigate":
562 ctx := context.Background()
563 res, err := parserService.ParseInput(ctx, m.URL)
564 if err == nil {
565 currentURL = res.URL
566 if _, navErr := browserService.Navigate(ctx, currentURL); navErr != nil {
567 log.Printf("Warning: failed to navigate to %s: %v", currentURL, navErr)
568 }
569 _ = view.LoadURL(currentURL)
570 if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil {
571 programmaticZoomChange = true
572 _ = view.SetZoom(z)
573 programmaticZoomChange = false
574 }
575 }
576 case "query":
577 ctx := context.Background()
578 lim := m.Limit
579 if lim <= 0 || lim > 25 {
580 lim = 10
581 }
582 entries, err := browserService.SearchHistory(ctx, m.Q, lim)
583 if err != nil {
584 return
585 }
586 // Map to lightweight items
587 type item struct {
588 URL string `json:"url"`
589 Favicon string `json:"favicon"`
590 }
591 buildFavicon := func(raw string) string {
592 u, err := parserService.ParseInput(ctx, raw)
593 if err != nil || u.URL == "" {
594 return ""
595 }
596 parsed, perr := neturl.Parse(u.URL)
597 if perr != nil || parsed.Host == "" {
598 return ""
599 }
600 scheme := parsed.Scheme
601 if scheme == "" {
602 scheme = "https"
603 }
604 return scheme + "://" + parsed.Host + "/favicon.ico"
605 }
606 items := make([]item, 0, len(entries))
607 for _, e := range entries {
608 items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)})
609 }
610 b, _ := json.Marshal(items)
611 // Update suggestions in page
612 _ = view.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")")
613 case "wails":
614 // Handle Wails runtime fetch bridge calls for homepage
615 // Payload contains { methodID, methodName?, args }
616 var p struct {
617 MethodID uint32 `json:"methodID"`
618 MethodName string `json:"methodName"`
619 Args json.RawMessage `json:"args"`
620 }
621 if err := json.Unmarshal(m.Payload, &p); err != nil {
622 return
623 }
624 // Only implement the IDs we need
625 switch p.MethodID {
626 case hashGetRecentHistory: // BrowserService.GetRecentHistory(limit)
627 var args []interface{}
628 _ = json.Unmarshal(p.Args, &args)
629 limit := 50
630 if len(args) > 0 {
631 if f, ok := args[0].(float64); ok {
632 limit = int(f)
633 }
634 }
635 ctx := context.Background()
636 entries, err := browserService.GetRecentHistory(ctx, limit)
637 if err != nil {
638 return
639 }
640 resp, _ := json.Marshal(entries)
641 _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")")
642 case hashGetSearchShortcuts: // BrowserService.GetSearchShortcuts()
643 ctx := context.Background()
644 shortcuts, err := browserService.GetSearchShortcuts(ctx)
645 if err != nil {
646 return
647 }
648 resp, _ := json.Marshal(shortcuts)
649 _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")")
650 default:
651 // Return empty JSON to avoid breaking UI
652 _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', '{}')")
653 }
654 case "theme":
655 if m.Value != "" {
656 log.Printf("[theme] page reported color-scheme: %s", m.Value)
657 }
658 }
659 })
660 // Apply initial zoom setting and log it before showing the window
661 {
662 ctx := context.Background()
663 u := view.GetCurrentURL()
664 if u == "" {
665 u = "dumb://homepage"
666 }
667 if z, zerr := browserService.GetZoomLevel(ctx, u); zerr == nil {
668 programmaticZoomChange = true
669 if err := view.SetZoom(z); err != nil {
670 log.Printf("Warning: failed to set initial zoom: %v", err)
671 } else {
672 key := services.ZoomKeyForLog(u)
673 log.Printf("[zoom] loaded %.2f for %s", z, key)
674 }
675 programmaticZoomChange = false
676 }
677 }
678
679 log.Printf("Showing WebView window…")
680 if err := view.Show(); err != nil {
681 log.Printf("Warning: failed to show WebView: %v", err)
682 } else if !webkit.IsNativeAvailable() {
683 log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
684 }
685 }
686
687 // Parse browse argument if present
688 if len(os.Args) >= 3 && os.Args[1] == "browse" {
689 log.Printf("Browse command detected: %s", os.Args[2])
690 ctx := context.Background()
691 result, err := parserService.ParseInput(ctx, os.Args[2])
692 if err == nil {
693 currentURL = result.URL
694 log.Printf("Parsed input → URL: %s", currentURL)
695 if _, navErr := browserService.Navigate(ctx, currentURL); navErr != nil {
696 log.Printf("Warning: failed to navigate to %s: %v", currentURL, navErr)
697 }
698 if view != nil {
699 log.Printf("Loading URL in WebView: %s", currentURL)
700 if err := view.LoadURL(currentURL); err != nil {
701 log.Printf("Warning: failed to load URL: %v", err)
702 }
703 if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil {
704 programmaticZoomChange = true
705 if err := view.SetZoom(z); err != nil {
706 log.Printf("Warning: failed to set zoom: %v", err)
707 } else {
708 key := services.ZoomKeyForLog(currentURL)
709 log.Printf("[zoom] loaded %.2f for %s", z, key)
710 }
711 programmaticZoomChange = false
712 }
713 // No JS injection; native accelerators handle zoom/devtools.
714 }
715 }
716 }
717
718 // Register basic keyboard shortcuts on the native view to preserve behavior
719 if view != nil {
720 // DevTools
721 _ = view.RegisterKeyboardShortcut("F12", func() { log.Printf("Shortcut: F12 (devtools)"); _ = view.ShowDevTools() })
722 // Omnibox (Ctrl+L): rely on injected script listener
723 _ = view.RegisterKeyboardShortcut("cmdorctrl+l", func() {
724 log.Printf("Shortcut: Omnibox toggle")
725 _ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()")
726 })
727 // Copy URL (Ctrl+Shift+C)
728 _ = view.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() {
729 log.Printf("Shortcut: Copy URL")
730 currentURL := view.GetCurrentURL()
731 if currentURL == "" {
732 log.Printf("No URL to copy")
733 _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`)
734 return
735 }
736
737 if err := clipboard.CopyToClipboard(currentURL); err != nil {
738 log.Printf("Failed to copy URL to clipboard: %v", err)
739 _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`)
740 } else {
741 log.Printf("URL copied to clipboard: %s", currentURL)
742 _ = view.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("URL copied to clipboard", 2000)`)
743 }
744 })
745 // Page refresh shortcuts
746 _ = view.RegisterKeyboardShortcut("cmdorctrl+r", func() {
747 log.Printf("Shortcut: Reload page")
748 _ = view.Reload()
749 })
750 _ = view.RegisterKeyboardShortcut("cmdorctrl+shift+r", func() {
751 log.Printf("Shortcut: Hard reload page")
752 _ = view.ReloadBypassCache()
753 })
754 _ = view.RegisterKeyboardShortcut("F5", func() {
755 log.Printf("Shortcut: F5 reload")
756 _ = view.Reload()
757 })
758 // Zoom handled natively in webkit package (built-in shortcuts)
759 }
760
761 // Set up signal handling for graceful shutdown
762 sigChan := make(chan os.Signal, 1)
763 signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
764
765 // Enter GTK main loop only when native backend is available.
766 if webkit.IsNativeAvailable() {
767 // Handle signals in a goroutine to quit the main loop
768 go func() {
769 sig := <-sigChan
770 log.Printf("Received signal %v - shutting down gracefully", sig)
771 webkit.QuitMainLoop()
772 }()
773
774 log.Printf("Entering GTK main loop…")
775 webkit.RunMainLoop()
776 log.Printf("GTK main loop exited")
777 } else {
778 log.Printf("Not entering GUI loop (non-CGO build)")
779 // In non-CGO mode, just wait for signals
780 sig := <-sigChan
781 log.Printf("Received signal %v - exiting", sig)
782 }
783}
784
785// readMemoryConfigFromEnv reads optional memory optimization knobs from environment variables.
786// Defaults favor performance; only applied when env vars are set.
787func readMemoryConfigFromEnv() webkit.MemoryConfig {
788 var mc webkit.MemoryConfig
789
790 // Cache model (document_viewer | web_browser | primary_web_browser)
791 if v := os.Getenv("DUMBER_CACHE_MODEL"); v != "" {
792 switch strings.ToLower(v) {
793 case "document_viewer", "documentviewer", "doc":
794 mc.CacheModel = webkit.CacheModelDocumentViewer
795 case "primary_web_browser", "primary", "primarywebbrowser":
796 mc.CacheModel = webkit.CacheModelPrimaryWebBrowser
797 default:
798 mc.CacheModel = webkit.CacheModelWebBrowser
799 }
800 }
801
802 // Page cache toggle (default true, only override if provided)
803 if v := os.Getenv("DUMBER_ENABLE_PAGE_CACHE"); v != "" {
804 mc.EnablePageCache = v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
805 } else {
806 mc.EnablePageCache = true
807 }
808
809 // Memory limit (MB)
810 if v := os.Getenv("DUMBER_MEMORY_LIMIT_MB"); v != "" {
811 if n, err := strconv.Atoi(v); err == nil && n >= 0 {
812 mc.MemoryLimitMB = n
813 }
814 }
815
816 // Thresholds
817 if v := os.Getenv("DUMBER_MEMORY_CONSERVATIVE"); v != "" {
818 if f, err := strconv.ParseFloat(v, 64); err == nil {
819 mc.ConservativeThreshold = f
820 }
821 }
822 if v := os.Getenv("DUMBER_MEMORY_STRICT"); v != "" {
823 if f, err := strconv.ParseFloat(v, 64); err == nil {
824 mc.StrictThreshold = f
825 }
826 }
827 if v := os.Getenv("DUMBER_MEMORY_KILL"); v != "" {
828 if f, err := strconv.ParseFloat(v, 64); err == nil {
829 mc.KillThreshold = f
830 }
831 }
832 if v := os.Getenv("DUMBER_MEMORY_POLL_INTERVAL"); v != "" {
833 if f, err := strconv.ParseFloat(v, 64); err == nil {
834 mc.PollIntervalSeconds = f
835 }
836 }
837
838 // Periodic GC (seconds)
839 if v := os.Getenv("DUMBER_GC_INTERVAL"); v != "" {
840 if n, err := strconv.Atoi(v); err == nil && n >= 0 {
841 mc.EnableGCInterval = n
842 }
843 }
844
845 // Recycle after N page loads
846 if v := os.Getenv("DUMBER_RECYCLE_THRESHOLD"); v != "" {
847 if n, err := strconv.Atoi(v); err == nil && n >= 0 {
848 mc.ProcessRecycleThreshold = n
849 }
850 }
851
852 // Enable memory monitoring logs
853 if v := os.Getenv("DUMBER_ENABLE_MEMORY_MONITORING"); v != "" {
854 mc.EnableMemoryMonitoring = v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
855 }
856
857 return mc
858}
859
860// detectAndSetKeyboardLocale attempts to determine the current keyboard layout
861// and hints it to the webkit input layer for accelerator compatibility.
862func detectAndSetKeyboardLocale() {
863 // 0) Explicit override
864 locale := os.Getenv("DUMBER_KEYBOARD_LOCALE")
865 if locale == "" {
866 locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix
867 }
868 // 1) Environment
869 if locale == "" {
870 locale = os.Getenv("LC_ALL")
871 }
872 if locale == "" {
873 locale = os.Getenv("LANG")
874 }
875 if locale == "" {
876 locale = os.Getenv("LC_CTYPE")
877 }
878 // Trim variants
879 if i := strings.IndexByte(locale, '.'); i > 0 {
880 locale = locale[:i]
881 }
882 if i := strings.IndexByte(locale, '@'); i > 0 {
883 locale = locale[:i]
884 }
885 if i := strings.IndexByte(locale, '_'); i > 0 {
886 locale = locale[:i]
887 }
888
889 // 2) Try XKB env override
890 if locale == "" {
891 locale = os.Getenv("XKB_DEFAULT_LAYOUT")
892 }
893
894 // 3) Best-effort probe of setxkbmap/localectl (non-fatal)
895 if locale == "" {
896 if out, err := exec.Command("localectl", "status").Output(); err == nil {
897 s := string(out)
898 for _, line := range strings.Split(s, "\n") {
899 line = strings.TrimSpace(line)
900 if strings.HasPrefix(strings.ToLower(line), "x11 layout:") || strings.HasPrefix(strings.ToLower(line), "keyboard layout:") {
901 parts := strings.SplitN(line, ":", colonSplitParts)
902 if len(parts) == colonSplitParts {
903 cand := strings.TrimSpace(parts[1])
904 if cand != "" {
905 locale = cand
906 }
907 }
908 break
909 }
910 }
911 }
912 }
913 if locale == "" {
914 if out, err := exec.Command("setxkbmap", "-query").Output(); err == nil {
915 s := string(out)
916 for _, line := range strings.Split(s, "\n") {
917 line = strings.TrimSpace(line)
918 if strings.HasPrefix(strings.ToLower(line), "layout:") {
919 parts := strings.SplitN(line, ":", colonSplitParts)
920 if len(parts) == colonSplitParts {
921 cand := strings.TrimSpace(parts[1])
922 if cand != "" {
923 locale = cand
924 }
925 }
926 break
927 }
928 }
929 }
930 }
931
932 if locale == "" {
933 locale = "en"
934 }
935 // No layout-specific remaps; log for diagnostics only
936 log.Printf("[locale] keyboard locale detected: %s", locale)
937}
938
939// applyCodecConfiguration applies codec preferences to environment variables and GStreamer settings
940func applyCodecConfiguration(codecPrefs config.CodecConfig) {
941 log.Printf("[codec] Applying codec preferences: preferred=%s, force_av1=%t, block_vp9=%t",
942 codecPrefs.PreferredCodecs, codecPrefs.ForceAV1, codecPrefs.BlockVP9)
943
944 // Build comprehensive GStreamer plugin rankings
945 var rankSettings []string
946
947 // Get existing GST_PLUGIN_FEATURE_RANK or start fresh
948 existingRank := os.Getenv("GST_PLUGIN_FEATURE_RANK")
949 if existingRank != "" {
950 rankSettings = strings.Split(existingRank, ",")
951 }
952
953 // AV1 decoder promotions (high priority)
954 if codecPrefs.ForceAV1 || strings.Contains(codecPrefs.PreferredCodecs, "av1") {
955 av1Ranks := []string{
956 "vaav1dec:768", // VA-API AV1 decoder (highest priority)
957 "nvav1dec:512", // NVDEC AV1 decoder
958 "av1dec:384", // Software AV1 decoder (fallback)
959 "avdec_av1:256", // libav AV1 decoder
960 }
961 rankSettings = append(rankSettings, av1Ranks...)
962 log.Printf("[codec] Promoted AV1 decoders for maximum priority")
963 }
964
965 // H.264 decoder management
966 if strings.Contains(codecPrefs.PreferredCodecs, "h264") {
967 h264Ranks := []string{
968 "vah264dec:512", // VA-API H.264 decoder
969 "nvh264dec:384", // NVDEC H.264 decoder
970 "avdec_h264:256", // Software H.264 decoder
971 }
972 rankSettings = append(rankSettings, h264Ranks...)
973 }
974
975 // VP9 decoder demotion/blocking
976 if codecPrefs.DisableVP9Hardware || codecPrefs.BlockVP9 {
977 vp9Demotions := []string{
978 "vaapivp9dec:0", // Block VA-API VP9
979 "nvvp9dec:0", // Block NVDEC VP9
980 }
981 if codecPrefs.BlockVP9 {
982 vp9Demotions = append(vp9Demotions, "vp9dec:0", "avdec_vp9:0") // Block all VP9
983 } else {
984 // Allow software VP9 with low priority
985 vp9Demotions = append(vp9Demotions, "vp9dec:64", "avdec_vp9:64")
986 }
987 rankSettings = append(rankSettings, vp9Demotions...)
988 log.Printf("[codec] Demoted/blocked VP9 decoders")
989 }
990
991 // VP8 decoder blocking
992 if codecPrefs.BlockVP8 {
993 vp8Demotions := []string{
994 "vaapivp8dec:0",
995 "vp8dec:0",
996 "avdec_vp8:0",
997 }
998 rankSettings = append(rankSettings, vp8Demotions...)
999 log.Printf("[codec] Blocked VP8 decoders")
1000 }
1001
1002 // Apply the complete ranking system
1003 if len(rankSettings) > 0 {
1004 finalRank := strings.Join(rankSettings, ",")
1005 if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", finalRank); err != nil {
1006 log.Printf("[codec] Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err)
1007 } else {
1008 log.Printf("[codec] Set comprehensive GST_PLUGIN_FEATURE_RANK: %s", finalRank)
1009 }
1010 }
1011
1012 // Set video buffer sizes if specified
1013 if codecPrefs.VideoBufferSizeMB > 0 {
1014 bufferSize := strconv.Itoa(codecPrefs.VideoBufferSizeMB * 1024 * 1024)
1015
1016 if err := os.Setenv("GST_BUFFER_SIZE", bufferSize); err != nil {
1017 log.Printf("[codec] Warning: failed to set GST_BUFFER_SIZE: %v", err)
1018 }
1019
1020 if err := os.Setenv("GST_QUEUE2_MAX_SIZE_BYTES", bufferSize); err != nil {
1021 log.Printf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_BYTES: %v", err)
1022 }
1023
1024 log.Printf("[codec] Set video buffer size to %dMB", codecPrefs.VideoBufferSizeMB)
1025 }
1026
1027 // Set queue buffer time if specified
1028 if codecPrefs.QueueBufferTimeSec > 0 {
1029 // Convert seconds to nanoseconds for GStreamer
1030 bufferTime := strconv.Itoa(codecPrefs.QueueBufferTimeSec * 1000000000)
1031
1032 if err := os.Setenv("GST_QUEUE2_MAX_SIZE_TIME", bufferTime); err != nil {
1033 log.Printf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_TIME: %v", err)
1034 } else {
1035 log.Printf("[codec] Set queue buffer time to %ds", codecPrefs.QueueBufferTimeSec)
1036 }
1037 }
1038
1039 // Set additional codec-specific environment variables
1040 if codecPrefs.ForceAV1 {
1041 // Enable AV1 hardware decoding if available
1042 if err := os.Setenv("GST_AV1_DECODER_ENABLE_HW", "1"); err != nil {
1043 log.Printf("[codec] Warning: failed to set GST_AV1_DECODER_ENABLE_HW: %v", err)
1044 }
1045 }
1046
1047 // Disable video post-processing to avoid VA-API issues
1048 if err := os.Setenv("GST_VAAPI_DISABLE_VPP", "1"); err != nil {
1049 log.Printf("[codec] Warning: failed to set GST_VAAPI_DISABLE_VPP: %v", err)
1050 }
1051
1052 log.Printf("[codec] Codec configuration applied successfully")
1053}
1054
1055// buildBlockedCodecsList converts config codec blocking preferences to a string slice
1056func buildBlockedCodecsList(codecPrefs config.CodecConfig) []string {
1057 var blocked []string
1058
1059 if codecPrefs.BlockVP9 {
1060 blocked = append(blocked, "vp9")
1061 }
1062 if codecPrefs.BlockVP8 {
1063 blocked = append(blocked, "vp8")
1064 }
1065
1066 return blocked
1067}
1068
1069// getHomepageFaviconSVG returns the SVG used as a favicon for the homepage
1070// when WebKit requests dumb://homepage/favicon.ico.
1071// getHomepageFaviconSVG was inlined previously; now favicon.svg is emitted at build time.