v0.5.0 f906adb

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	// 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.

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