v0.6.0 5c50dd3

main.go RawHistoryFolder

1package main
2
3import (
4	"context"
5	"embed"
6	"encoding/json"
7	"fmt"
8	"log"
9	"mime"
10	neturl "net/url"
11	"os"
12	"os/exec"
13	"os/signal"
14	"path/filepath"
15	"runtime"
16	"strconv"
17	"strings"
18	"syscall"
19
20	"github.com/bnema/dumber/internal/cli"
21	"github.com/bnema/dumber/internal/config"
22	"github.com/bnema/dumber/internal/db"
23	"github.com/bnema/dumber/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.

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