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}