detached e4b7498

refactor(main): extract CLI and browser logic to separate app modules

commit
e4b74982c60a50bd43217e904a53942094c50cc3
author
bamen · · 2025-09-12 13:03 UTC
parent
11227e8
signature
unsigned
1 file · +14 -1024
M main.go +14 -1024
@@ -1,31 +1,18 @@
11 package main
22
33 import (
4- "context"
54 "embed"
6- "encoding/json"
7- "fmt"
8- "log"
9- "mime"
10- neturl "net/url"
115 "os"
12- "os/exec"
13- "os/signal"
14- "path/filepath"
156 "runtime"
16- "strconv"
17- "strings"
18- "syscall"
197
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"
8+ "github.com/bnema/dumber/internal/app/browser"
9+ "github.com/bnema/dumber/internal/app/cli"
2510 "github.com/bnema/dumber/pkg/webkit"
26- "github.com/bnema/dumber/services"
2711 )
2812
13+//go:embed frontend/dist
14+var assets embed.FS
15+
2916 // Build information set via ldflags
3017 var (
3118 version = "dev"
@@ -33,31 +20,21 @@ var (
3320 buildDate = "unknown"
3421 )
3522
36-// Constants for repeated strings
37-const (
38- contentTypeJSON = "application/json; charset=utf-8"
39- homepagePath = "homepage"
40- indexHTML = "index.html"
41-)
42-
43-// Parsing and utility constants
44-const (
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-
5223 func main() {
5324 // Check if we should run the CLI mode
5425 if shouldRunCLI() {
55- runCLI()
26+ cli.Execute(version, commit, buildDate)
5627 return
5728 }
5829
30+ // GTK requires all UI calls to run on the main OS thread
31+ if webkit.IsNativeAvailable() {
32+ runtime.LockOSThread()
33+ defer runtime.UnlockOSThread()
34+ }
35+
5936 // Otherwise run the GUI browser
60- runBrowser()
37+ browser.Run(assets, version, commit, buildDate)
6138 }
6239
6340 // shouldRunCLI determines if we should run in CLI mode based on arguments
@@ -81,991 +58,4 @@ func shouldRunCLI() bool {
8158
8259 // Any other arguments mean CLI mode
8360 return true
84-}
85-
86-// runCLI executes the CLI functionality using the existing CLI package
87-func 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
151-var assets embed.FS
152-
153-// runBrowser launches a minimal native WebKit window and integrates services.
154-func 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.
787-func 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.
862-func 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
940-func 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
1056-func 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.
61+}⏎̸
@@ -1,31 +1,18 @@
1 package main1 package main
2 2
3 import (3 import (
4- "context"
5 "embed"4 "embed"
6- "encoding/json"
7- "fmt"
8- "log"
9- "mime"
10- neturl "net/url"
11 "os"5 "os"
12- "os/exec"
13- "os/signal"
14- "path/filepath"
15 "runtime"6 "runtime"
16- "strconv"
17- "strings"
18- "syscall"
19 7
20- "github.com/bnema/dumber/internal/cli"8+ "github.com/bnema/dumber/internal/app/browser"
21- "github.com/bnema/dumber/internal/config"9+ "github.com/bnema/dumber/internal/app/cli"
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"10 "github.com/bnema/dumber/pkg/webkit"
26- "github.com/bnema/dumber/services"
27 )11 )
28 12
13+//go:embed frontend/dist
14+var assets embed.FS
15+
29 // Build information set via ldflags16 // Build information set via ldflags
30 var (17 var (
31 version = "dev"18 version = "dev"
@@ -33,31 +20,21 @@ var (
33 buildDate = "unknown"20 buildDate = "unknown"
34 )21 )
35 22
36-// Constants for repeated strings
37-const (
38- contentTypeJSON = "application/json; charset=utf-8"
39- homepagePath = "homepage"
40- indexHTML = "index.html"
41-)
42-
43-// Parsing and utility constants
44-const (
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-
52 func main() {23 func main() {
53 // Check if we should run the CLI mode24 // Check if we should run the CLI mode
54 if shouldRunCLI() {25 if shouldRunCLI() {
55- runCLI()26+ cli.Execute(version, commit, buildDate)
56 return27 return
57 }28 }
58 29
30+ // GTK requires all UI calls to run on the main OS thread
31+ if webkit.IsNativeAvailable() {
32+ runtime.LockOSThread()
33+ defer runtime.UnlockOSThread()
34+ }
35+
59 // Otherwise run the GUI browser36 // Otherwise run the GUI browser
60- runBrowser()37+ browser.Run(assets, version, commit, buildDate)
61 }38 }
62 39
63 // shouldRunCLI determines if we should run in CLI mode based on arguments40 // shouldRunCLI determines if we should run in CLI mode based on arguments
@@ -81,991 +58,4 @@ func shouldRunCLI() bool {
81 58
82 // Any other arguments mean CLI mode59 // Any other arguments mean CLI mode
83 return true60 return true
84-}61+}⏎̸
85-
86-// runCLI executes the CLI functionality using the existing CLI package
87-func 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
151-var assets embed.FS
152-
153-// runBrowser launches a minimal native WebKit window and integrates services.
154-func 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.
787-func 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.
862-func 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
940-func 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
1056-func 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