v0.2.0 759cc92

main.go RawHistoryFolder

1package main
2
3import (
4    "context"
5    "embed"
6    "encoding/json"
7    "fmt"
8    "mime"
9    "log"
10    "os"
11    "runtime"
12    neturl "net/url"
13    "path/filepath"
14    "strings"
15    "strconv"
16    
17
18    "github.com/bnema/dumber/internal/cli"
19    "github.com/bnema/dumber/internal/config"
20    "github.com/bnema/dumber/internal/db"
21    "github.com/bnema/dumber/services"
22    "github.com/bnema/dumber/pkg/webkit"
23)
24
25// Build information set via ldflags
26var (
27	version   = "dev"
28	commit    = "none"
29	buildDate = "unknown"
30)
31
32func main() {
33	// Check if we should run the CLI mode
34	if shouldRunCLI() {
35		runCLI()
36		return
37	}
38
39    // Otherwise run the GUI browser
40    runBrowser()
41}
42
43// shouldRunCLI determines if we should run in CLI mode based on arguments
44func shouldRunCLI() bool {
45	// No args = GUI landing page mode
46	if len(os.Args) <= 1 {
47		return false
48	}
49
50	// Check for GUI-specific flags
51	for _, arg := range os.Args[1:] {
52		if arg == "--gui" || arg == "-g" {
53			return false // Explicit GUI mode
54		}
55	}
56
57	// Check for browse command - this should open GUI in direct navigation mode
58	if len(os.Args) >= 2 && os.Args[1] == "browse" {
59		return false // Browse command uses GUI mode but navigates directly
60	}
61
62	// Any other arguments mean CLI mode
63	return true
64}
65
66// runCLI executes the CLI functionality using the existing CLI package
67func runCLI() {
68	// Initialize configuration system
69	if err := config.Init(); err != nil {
70		fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err)
71		os.Exit(1)
72	}
73
74	// Start configuration watching for live reload
75	if err := config.Watch(); err != nil {
76		// Don't exit on watch error, just warn
77		fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err)
78	}
79
80	rootCmd := cli.NewRootCmd(version, commit, buildDate)
81
82	// Handle dmenu flag at the root level for direct integration
83	if len(os.Args) > 1 && os.Args[1] == "--dmenu" {
84		// Create a temporary CLI instance to handle dmenu mode
85		cliInstance, err := cli.NewCLI()
86		if err != nil {
87			fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err)
88			os.Exit(1)
89		}
90		defer func() {
91			if closeErr := cliInstance.Close(); closeErr != nil {
92				fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
93			}
94		}()
95
96		// Generate dmenu options
97		dmenuCmd := cli.NewDmenuCmd()
98		if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil {
99			fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err)
100			os.Exit(1)
101		}
102		return
103	}
104
105	// Execute the root command
106	if err := rootCmd.Execute(); err != nil {
107		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
108		os.Exit(1)
109    }
110}
111
112//go:embed frontend/dist
113var assets embed.FS
114
115// runBrowser launches a minimal native WebKit window and integrates services.
116func runBrowser() {
117    log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable())
118    // GTK requires all UI calls to run on the main OS thread
119    if webkit.IsNativeAvailable() {
120        runtime.LockOSThread()
121        defer runtime.UnlockOSThread()
122    }
123
124    // Proactively harden media pipeline to avoid WebProcess crashes from buggy VAAPI/plugins.
125    // Can be disabled with DUMBER_MEDIA_SAFE=0
126    if os.Getenv("DUMBER_MEDIA_SAFE") != "0" {
127        // Demote VAAPI-related elements so they won't be auto-picked
128        if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" {
129            os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0")
130        }
131        // Provide a safe audio sink to avoid autoaudiosink failures
132        if os.Getenv("GST_AUDIO_SINK") == "" {
133            os.Setenv("GST_AUDIO_SINK", "fakesink")
134        }
135        // Optionally disable media stream features that can engage complex pipelines
136        if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" {
137            os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1")
138        }
139        log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)")
140    }
141
142    if err := config.Init(); err != nil {
143        log.Fatalf("Failed to initialize config: %v", err)
144    }
145    cfg := config.Get()
146    log.Printf("Config initialized")
147
148    // Initialize database
149    database, err := db.InitDB(cfg.Database.Path)
150    if err != nil {
151        log.Fatalf("Failed to initialize database: %v", err)
152    }
153    defer database.Close()
154    queries := db.New(database)
155    log.Printf("Database opened at %s", cfg.Database.Path)
156
157    // Initialize services
158    parserService := services.NewParserService(cfg, queries)
159    browserService := services.NewBrowserService(cfg, queries)
160    // JS controls injection is disabled; using native shortcuts via GTK.
161
162    // Track current URL for zoom persistence
163    // Initialize to the initial homepage so shortcuts work immediately
164    var currentURL string = "dumb://homepage"
165
166    // Register custom scheme resolver for dumb://
167    webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) {
168        log.Printf("[scheme] request: %s", uri)
169        // Known forms:
170        // - dumb://homepage or dumb:homepage → index.html
171        // - dumb://app/index.html, dumb://app/<path> → serve from frontend/dist/<path>
172        // - dumb://<anything> without path → index.html
173        u, err := neturl.Parse(uri)
174        if err != nil || u.Scheme != "dumb" {
175            return "", nil, false
176        }
177        // API routes
178        if u.Host == "api" || strings.HasPrefix(u.Opaque, "api") || strings.HasPrefix(u.Path, "/api") {
179            // Normalize path for opaque or hierarchical forms
180            path := u.Path
181            if path == "" && u.Opaque != "" {
182                // e.g., dumb:api/config or dumb:api/history/recent
183                parts := strings.SplitN(u.Opaque, ":", 2)
184                if len(parts) == 2 {
185                    path = "/" + parts[1]
186                }
187            }
188            if strings.HasPrefix(path, "/api/") { path = strings.TrimPrefix(path, "/api") }
189            if path == "/api" { path = "/" }
190            switch {
191            case strings.HasPrefix(path, "/rendering/status"):
192                log.Printf("[api] GET /rendering/status")
193                // Report configured rendering mode; runtime GPU state depends on WebKit internals
194                resp := struct{
195                    Mode string `json:"mode"`
196                }{Mode: string(cfg.RenderingMode)}
197                b, _ := json.Marshal(resp)
198                return "application/json; charset=utf-8", b, true
199            case strings.HasPrefix(path, "/config"):
200                log.Printf("[api] GET /config")
201                // Build config info
202                cfgPath, _ := config.GetConfigFile()
203                info := struct {
204                    ConfigPath     string                                    `json:"config_path"`
205                    DatabasePath   string                                    `json:"database_path"`
206                    SearchShortcuts map[string]config.SearchShortcut          `json:"search_shortcuts"`
207                    Appearance     config.AppearanceConfig                    `json:"appearance"`
208                }{
209                    ConfigPath: cfgPath,
210                    DatabasePath: cfg.Database.Path,
211                    SearchShortcuts: cfg.SearchShortcuts,
212                    Appearance: cfg.Appearance,
213                }
214                b, _ := json.Marshal(info)
215                return "application/json; charset=utf-8", b, true
216            case strings.HasPrefix(path, "/history/recent"):
217                log.Printf("[api] GET /history/recent%s", u.RawQuery)
218                // Parse limit
219                q := u.Query()
220                limit := 50
221                if l := q.Get("limit"); l != "" {
222                    if n, err := strconv.Atoi(l); err == nil && n > 0 {
223                        limit = n
224                    }
225                }
226                ctx := context.Background()
227                entries, err := browserService.GetRecentHistory(ctx, limit)
228                if err != nil {
229                    return "application/json; charset=utf-8", []byte("[]"), true
230                }
231                b, _ := json.Marshal(entries)
232                return "application/json; charset=utf-8", b, true
233            case strings.HasPrefix(path, "/history/search"):
234                log.Printf("[api] GET /history/search%s", u.RawQuery)
235                q := u.Query()
236                query := q.Get("q")
237                limit := 50
238                if l := q.Get("limit"); l != "" {
239                    if n, err := strconv.Atoi(l); err == nil && n > 0 {
240                        limit = n
241                    }
242                }
243                ctx := context.Background()
244                entries, err := browserService.SearchHistory(ctx, query, limit)
245                if err != nil {
246                    return "application/json; charset=utf-8", []byte("[]"), true
247                }
248                b, _ := json.Marshal(entries)
249                return "application/json; charset=utf-8", b, true
250            case strings.HasPrefix(path, "/history/stats"):
251                log.Printf("[api] GET /history/stats")
252                ctx := context.Background()
253                stats, err := browserService.GetHistoryStats(ctx)
254                if err != nil { return "application/json; charset=utf-8", []byte("{}"), true }
255                b, _ := json.Marshal(stats)
256                return "application/json; charset=utf-8", b, true
257            default:
258                return "application/json; charset=utf-8", []byte("{}"), true
259            }
260        }
261        // Resolve target path inside embed FS
262        var rel string
263        if u.Opaque == "homepage" || (u.Host == "homepage" && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) {
264            rel = "index.html"
265        } else {
266            host := u.Host
267            p := strings.TrimPrefix(u.Path, "/")
268            if host == "app" && p == "" {
269                rel = "index.html"
270            } else if host == "app" {
271                rel = p
272            } else if host == "homepage" && p != "" {
273                // dumb://homepage/<asset>
274                rel = p
275            } else if p != "" {
276                rel = p
277            } else {
278                rel = "index.html"
279            }
280        }
281        log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path)
282        data, rerr := assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel)))
283        if rerr != nil {
284            log.Printf("[scheme] not found: %s", rel)
285            return "", nil, false
286        }
287        // Determine mime type
288        mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel)))
289        if mt == "" {
290            // Fallbacks
291            switch strings.ToLower(filepath.Ext(rel)) {
292            case ".js": mt = "application/javascript"
293            case ".css": mt = "text/css"
294            case ".svg": mt = "image/svg+xml"
295            case ".ico": mt = "image/x-icon"
296            default: mt = "text/html; charset=utf-8"
297            }
298        }
299        return mt, data, true
300    })
301
302    // Create WebKit view
303    log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
304    dataDir, _ := config.GetDataDir()
305    stateDir, _ := config.GetStateDir()
306    webkitData := filepath.Join(dataDir, "webkit")
307    webkitCache := filepath.Join(stateDir, "webkit-cache")
308    _ = os.MkdirAll(webkitData, 0o755)
309    _ = os.MkdirAll(webkitCache, 0o755)
310    view, err := webkit.NewWebView(&webkit.Config{
311        InitialURL:        "dumb://homepage",
312        ZoomDefault:       1.0,
313        EnableDeveloperExtras: true,
314        DataDir:           webkitData,
315        CacheDir:          webkitCache,
316        DefaultSansFont:      cfg.Appearance.SansFont,
317        DefaultSerifFont:     cfg.Appearance.SerifFont,
318        DefaultMonospaceFont: cfg.Appearance.MonospaceFont,
319        DefaultFontSize:      cfg.Appearance.DefaultFontSize,
320        Rendering:            webkit.RenderingConfig{Mode: string(cfg.RenderingMode)},
321    })
322    if err != nil {
323        log.Printf("Warning: failed to create WebView: %v", err)
324    } else {
325        browserService.AttachWebView(view)
326        // Use native window as title updater
327        if win := view.Window(); win != nil {
328            browserService.SetWindowTitleUpdater(win)
329        }
330        // Persist page titles to DB when they change
331        view.RegisterTitleChangedHandler(func(title string) {
332            ctx := context.Background()
333            url := view.GetCurrentURL()
334            if url != "" && title != "" {
335                if err := browserService.UpdatePageTitle(ctx, url, title); err != nil {
336                    log.Printf("Warning: failed to update page title: %v", err)
337                }
338            }
339        })
340        // Track current URL changes and apply saved zoom
341        view.RegisterURIChangedHandler(func(u string) {
342            currentURL = u
343            if u == "" { return }
344            ctx := context.Background()
345            if z, err := browserService.GetZoomLevel(ctx, u); err == nil {
346                _ = view.SetZoom(z)
347            }
348        })
349        // Bridge UCM messages: navigate + history queries
350        view.RegisterScriptMessageHandler(func(payload string) {
351            type msg struct {
352                Type   string `json:"type"`
353                URL    string `json:"url"`
354                Q      string `json:"q"`
355                Limit  int    `json:"limit"`
356                Value  string `json:"value"`
357                // Wails fetch bridge
358                ID     string          `json:"id"`
359                Payload json.RawMessage `json:"payload"`
360            }
361            var m msg
362            if err := json.Unmarshal([]byte(payload), &m); err != nil { return }
363            switch m.Type {
364            case "navigate":
365                ctx := context.Background()
366                res, err := parserService.ParseInput(ctx, m.URL)
367                if err == nil {
368                    currentURL = res.URL
369                    browserService.Navigate(ctx, currentURL)
370                    _ = view.LoadURL(currentURL)
371                    if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil { _ = view.SetZoom(z) }
372                }
373            case "query":
374                ctx := context.Background()
375                lim := m.Limit
376                if lim <= 0 || lim > 25 { lim = 10 }
377                entries, err := browserService.SearchHistory(ctx, m.Q, lim)
378                if err != nil { return }
379                // Map to lightweight items
380                type item struct { URL string `json:"url"`; Favicon string `json:"favicon"` }
381                buildFavicon := func(raw string) string {
382                    u, err := parserService.ParseInput(ctx, raw)
383                    if err != nil || u.URL == "" { return "" }
384                    parsed, perr := neturl.Parse(u.URL)
385                    if perr != nil || parsed.Host == "" { return "" }
386                    scheme := parsed.Scheme
387                    if scheme == "" { scheme = "https" }
388                    return scheme + "://" + parsed.Host + "/favicon.ico"
389                }
390                items := make([]item, 0, len(entries))
391                for _, e := range entries { items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)}) }
392                b, _ := json.Marshal(items)
393                // Update suggestions in page
394                _ = view.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")")
395            case "wails":
396                // Handle Wails runtime fetch bridge calls for homepage
397                // Payload contains { methodID, methodName?, args }
398                var p struct {
399                    MethodID uint32           `json:"methodID"`
400                    MethodName string         `json:"methodName"`
401                    Args     json.RawMessage  `json:"args"`
402                }
403                if err := json.Unmarshal(m.Payload, &p); err != nil { return }
404                // Only implement the IDs we need
405                switch p.MethodID {
406                case 3708519028: // BrowserService.GetRecentHistory(limit)
407                    var args []interface{}
408                    _ = json.Unmarshal(p.Args, &args)
409                    limit := 50
410                    if len(args) > 0 {
411                        if f, ok := args[0].(float64); ok { limit = int(f) }
412                    }
413                    ctx := context.Background()
414                    entries, err := browserService.GetRecentHistory(ctx, limit)
415                    if err != nil { return }
416                    resp, _ := json.Marshal(entries)
417                    _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")")
418                case 4078533762: // BrowserService.GetSearchShortcuts()
419                    ctx := context.Background()
420                    shortcuts, err := browserService.GetSearchShortcuts(ctx)
421                    if err != nil { return }
422                    resp, _ := json.Marshal(shortcuts)
423                    _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', " + string(resp) + ")")
424                default:
425                    // Return empty JSON to avoid breaking UI
426                    _ = view.InjectScript("window.__dumber_wails_resolve('" + m.ID + "', '{}')")
427                }
428            case "theme":
429                if m.Value != "" { log.Printf("[theme] page reported color-scheme: %s", m.Value) }
430            }
431        })
432        log.Printf("Showing WebView window…")
433        if err := view.Show(); err != nil {
434            log.Printf("Warning: failed to show WebView: %v", err)
435        } else {
436            if !webkit.IsNativeAvailable() {
437                log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
438            }
439        }
440    }
441
442    // Parse browse argument if present
443    if len(os.Args) >= 3 && os.Args[1] == "browse" {
444        log.Printf("Browse command detected: %s", os.Args[2])
445        ctx := context.Background()
446        result, err := parserService.ParseInput(ctx, os.Args[2])
447        if err == nil {
448            currentURL = result.URL
449            log.Printf("Parsed input → URL: %s", currentURL)
450            browserService.Navigate(ctx, currentURL)
451            if view != nil {
452                log.Printf("Loading URL in WebView: %s", currentURL)
453                if err := view.LoadURL(currentURL); err != nil {
454                    log.Printf("Warning: failed to load URL: %v", err)
455                }
456                if z, zerr := browserService.GetZoomLevel(ctx, currentURL); zerr == nil {
457                    log.Printf("Applying stored zoom: %.2f", z)
458                    if err := view.SetZoom(z); err != nil {
459                        log.Printf("Warning: failed to set zoom: %v", err)
460                    }
461                }
462                // No JS injection; native accelerators handle zoom/devtools.
463            }
464        }
465    }
466
467    // Register basic keyboard shortcuts on the native view to preserve behavior
468    if view != nil {
469        // DevTools
470        _ = view.RegisterKeyboardShortcut("F12", func() { log.Printf("Shortcut: F12 (devtools)"); _ = view.ShowDevTools() })
471        // Omnibox (Ctrl+L): rely on injected script listener
472        _ = view.RegisterKeyboardShortcut("cmdorctrl+l", func() {
473            log.Printf("Shortcut: Omnibox toggle")
474            _ = view.InjectScript("window.__dumber_toggle && window.__dumber_toggle()")
475        })
476        // Zoom handled natively in webkit package (built-in shortcuts)
477    }
478
479    // Enter GTK main loop only when native backend is available.
480    if webkit.IsNativeAvailable() {
481        log.Printf("Entering GTK main loop…")
482        webkit.RunMainLoop()
483        log.Printf("GTK main loop exited")
484    } else {
485        log.Printf("Not entering GUI loop (non-CGO build)")
486    }
487}

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