detached c6e449f

refactor(arch): extract browser logic to modular internal/app structure

commit
c6e449fca7e2c556b55492b0cda4f26344c40e26
author
bamen · · 2025-09-11 16:26 UTC
committer
bamen · · 2025-09-11 16:14 UTC
parent
faf9893
signature
unsigned
15 files · +1600 -0
A internal/app/api/routes.go +113 -0
@@ -0,0 +1,113 @@
1+package api
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "log"
7+ neturl "net/url"
8+ "strconv"
9+
10+ "github.com/bnema/dumber/internal/app/constants"
11+ "github.com/bnema/dumber/internal/config"
12+ "github.com/bnema/dumber/services"
13+)
14+
15+// Handler provides API endpoint handling functionality
16+type Handler struct {
17+ browserService *services.BrowserService
18+}
19+
20+// NewHandler creates a new API handler
21+func NewHandler(browserService *services.BrowserService) *Handler {
22+ return &Handler{
23+ browserService: browserService,
24+ }
25+}
26+
27+// HandleRenderingStatus handles GET /rendering/status endpoint
28+func (h *Handler) HandleRenderingStatus(cfg *config.Config) (string, []byte, bool) {
29+ log.Printf("[api] GET /rendering/status")
30+ // Report configured rendering mode; runtime GPU state depends on WebKit internals
31+ resp := struct {
32+ Mode string `json:"mode"`
33+ }{Mode: string(cfg.RenderingMode)}
34+ b, _ := json.Marshal(resp)
35+ return constants.ContentTypeJSON, b, true
36+}
37+
38+// HandleConfig handles GET /config endpoint
39+func (h *Handler) HandleConfig(cfg *config.Config) (string, []byte, bool) {
40+ log.Printf("[api] GET /config")
41+ // Build config info
42+ cfgPath, _ := config.GetConfigFile()
43+ info := struct {
44+ ConfigPath string `json:"config_path"`
45+ DatabasePath string `json:"database_path"`
46+ SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
47+ Appearance config.AppearanceConfig `json:"appearance"`
48+ }{
49+ ConfigPath: cfgPath,
50+ DatabasePath: cfg.Database.Path,
51+ SearchShortcuts: cfg.SearchShortcuts,
52+ Appearance: cfg.Appearance,
53+ }
54+ b, _ := json.Marshal(info)
55+ return constants.ContentTypeJSON, b, true
56+}
57+
58+// HandleHistoryRecent handles GET /history/recent endpoint
59+func (h *Handler) HandleHistoryRecent(u *neturl.URL) (string, []byte, bool) {
60+ log.Printf("[api] GET /history/recent%s", u.RawQuery)
61+ // Parse limit
62+ q := u.Query()
63+ limit := 50
64+ if l := q.Get("limit"); l != "" {
65+ if n, err := strconv.Atoi(l); err == nil && n > 0 {
66+ limit = n
67+ }
68+ }
69+ ctx := context.Background()
70+ entries, err := h.browserService.GetRecentHistory(ctx, limit)
71+ if err != nil {
72+ return constants.ContentTypeJSON, []byte("[]"), true
73+ }
74+ b, _ := json.Marshal(entries)
75+ return constants.ContentTypeJSON, b, true
76+}
77+
78+// HandleHistorySearch handles GET /history/search endpoint
79+func (h *Handler) HandleHistorySearch(u *neturl.URL) (string, []byte, bool) {
80+ log.Printf("[api] GET /history/search%s", u.RawQuery)
81+ q := u.Query()
82+ query := q.Get("q")
83+ limit := 50
84+ if l := q.Get("limit"); l != "" {
85+ if n, err := strconv.Atoi(l); err == nil && n > 0 {
86+ limit = n
87+ }
88+ }
89+ ctx := context.Background()
90+ entries, err := h.browserService.SearchHistory(ctx, query, limit)
91+ if err != nil {
92+ return constants.ContentTypeJSON, []byte("[]"), true
93+ }
94+ b, _ := json.Marshal(entries)
95+ return constants.ContentTypeJSON, b, true
96+}
97+
98+// HandleHistoryStats handles GET /history/stats endpoint
99+func (h *Handler) HandleHistoryStats() (string, []byte, bool) {
100+ log.Printf("[api] GET /history/stats")
101+ ctx := context.Background()
102+ stats, err := h.browserService.GetHistoryStats(ctx)
103+ if err != nil {
104+ return constants.ContentTypeJSON, []byte("{}"), true
105+ }
106+ b, _ := json.Marshal(stats)
107+ return constants.ContentTypeJSON, b, true
108+}
109+
110+// HandleDefault returns empty JSON for unknown endpoints
111+func (h *Handler) HandleDefault() (string, []byte, bool) {
112+ return constants.ContentTypeJSON, []byte("{}"), true
113+}
@@ -0,0 +1,113 @@
1+package api
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "log"
7+ neturl "net/url"
8+ "strconv"
9+
10+ "github.com/bnema/dumber/internal/app/constants"
11+ "github.com/bnema/dumber/internal/config"
12+ "github.com/bnema/dumber/services"
13+)
14+
15+// Handler provides API endpoint handling functionality
16+type Handler struct {
17+ browserService *services.BrowserService
18+}
19+
20+// NewHandler creates a new API handler
21+func NewHandler(browserService *services.BrowserService) *Handler {
22+ return &Handler{
23+ browserService: browserService,
24+ }
25+}
26+
27+// HandleRenderingStatus handles GET /rendering/status endpoint
28+func (h *Handler) HandleRenderingStatus(cfg *config.Config) (string, []byte, bool) {
29+ log.Printf("[api] GET /rendering/status")
30+ // Report configured rendering mode; runtime GPU state depends on WebKit internals
31+ resp := struct {
32+ Mode string `json:"mode"`
33+ }{Mode: string(cfg.RenderingMode)}
34+ b, _ := json.Marshal(resp)
35+ return constants.ContentTypeJSON, b, true
36+}
37+
38+// HandleConfig handles GET /config endpoint
39+func (h *Handler) HandleConfig(cfg *config.Config) (string, []byte, bool) {
40+ log.Printf("[api] GET /config")
41+ // Build config info
42+ cfgPath, _ := config.GetConfigFile()
43+ info := struct {
44+ ConfigPath string `json:"config_path"`
45+ DatabasePath string `json:"database_path"`
46+ SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
47+ Appearance config.AppearanceConfig `json:"appearance"`
48+ }{
49+ ConfigPath: cfgPath,
50+ DatabasePath: cfg.Database.Path,
51+ SearchShortcuts: cfg.SearchShortcuts,
52+ Appearance: cfg.Appearance,
53+ }
54+ b, _ := json.Marshal(info)
55+ return constants.ContentTypeJSON, b, true
56+}
57+
58+// HandleHistoryRecent handles GET /history/recent endpoint
59+func (h *Handler) HandleHistoryRecent(u *neturl.URL) (string, []byte, bool) {
60+ log.Printf("[api] GET /history/recent%s", u.RawQuery)
61+ // Parse limit
62+ q := u.Query()
63+ limit := 50
64+ if l := q.Get("limit"); l != "" {
65+ if n, err := strconv.Atoi(l); err == nil && n > 0 {
66+ limit = n
67+ }
68+ }
69+ ctx := context.Background()
70+ entries, err := h.browserService.GetRecentHistory(ctx, limit)
71+ if err != nil {
72+ return constants.ContentTypeJSON, []byte("[]"), true
73+ }
74+ b, _ := json.Marshal(entries)
75+ return constants.ContentTypeJSON, b, true
76+}
77+
78+// HandleHistorySearch handles GET /history/search endpoint
79+func (h *Handler) HandleHistorySearch(u *neturl.URL) (string, []byte, bool) {
80+ log.Printf("[api] GET /history/search%s", u.RawQuery)
81+ q := u.Query()
82+ query := q.Get("q")
83+ limit := 50
84+ if l := q.Get("limit"); l != "" {
85+ if n, err := strconv.Atoi(l); err == nil && n > 0 {
86+ limit = n
87+ }
88+ }
89+ ctx := context.Background()
90+ entries, err := h.browserService.SearchHistory(ctx, query, limit)
91+ if err != nil {
92+ return constants.ContentTypeJSON, []byte("[]"), true
93+ }
94+ b, _ := json.Marshal(entries)
95+ return constants.ContentTypeJSON, b, true
96+}
97+
98+// HandleHistoryStats handles GET /history/stats endpoint
99+func (h *Handler) HandleHistoryStats() (string, []byte, bool) {
100+ log.Printf("[api] GET /history/stats")
101+ ctx := context.Background()
102+ stats, err := h.browserService.GetHistoryStats(ctx)
103+ if err != nil {
104+ return constants.ContentTypeJSON, []byte("{}"), true
105+ }
106+ b, _ := json.Marshal(stats)
107+ return constants.ContentTypeJSON, b, true
108+}
109+
110+// HandleDefault returns empty JSON for unknown endpoints
111+func (h *Handler) HandleDefault() (string, []byte, bool) {
112+ return constants.ContentTypeJSON, []byte("{}"), true
113+}
A internal/app/api/scheme.go +151 -0
@@ -0,0 +1,151 @@
1+package api
2+
3+import (
4+ "embed"
5+ "log"
6+ "mime"
7+ neturl "net/url"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/bnema/dumber/internal/app/constants"
12+ "github.com/bnema/dumber/internal/config"
13+ "github.com/bnema/dumber/services"
14+)
15+
16+// SchemeHandler handles custom dumb:// scheme resolution
17+type SchemeHandler struct {
18+ assets embed.FS
19+ apiHandler *Handler
20+ parserService *services.ParserService
21+ browserService *services.BrowserService
22+}
23+
24+// NewSchemeHandler creates a new scheme handler
25+func NewSchemeHandler(
26+ assets embed.FS,
27+ parserService *services.ParserService,
28+ browserService *services.BrowserService,
29+) *SchemeHandler {
30+ return &SchemeHandler{
31+ assets: assets,
32+ apiHandler: NewHandler(browserService),
33+ parserService: parserService,
34+ browserService: browserService,
35+ }
36+}
37+
38+// Handle processes dumb:// scheme requests
39+func (s *SchemeHandler) Handle(uri string, cfg *config.Config) (string, []byte, bool) {
40+ log.Printf("[scheme] request: %s", uri)
41+ // Known forms:
42+ // - dumb://homepage or dumb:homepage → index.html
43+ // - dumb://app/index.html, dumb://app/<path> → serve from frontend/dist/<path>
44+ // - dumb://<anything> without path → index.html
45+ u, err := neturl.Parse(uri)
46+ if err != nil || u.Scheme != "dumb" {
47+ return "", nil, false
48+ }
49+
50+ // API routes
51+ if u.Host == "api" || strings.HasPrefix(u.Opaque, "api") || strings.HasPrefix(u.Path, "/api") {
52+ return s.handleAPIRoute(u, cfg)
53+ }
54+
55+ // Static assets
56+ return s.handleAsset(u)
57+}
58+
59+// handleAPIRoute processes API requests
60+func (s *SchemeHandler) handleAPIRoute(u *neturl.URL, cfg *config.Config) (string, []byte, bool) {
61+ // Normalize path for opaque or hierarchical forms
62+ path := u.Path
63+ if path == "" && u.Opaque != "" {
64+ // e.g., dumb:api/config or dumb:api/history/recent
65+ parts := strings.SplitN(u.Opaque, ":", constants.ColonSplitParts)
66+ if len(parts) == constants.ColonSplitParts {
67+ path = "/" + parts[1]
68+ }
69+ }
70+ if strings.HasPrefix(path, "/api/") {
71+ path = strings.TrimPrefix(path, "/api")
72+ }
73+ if path == "/api" {
74+ path = "/"
75+ }
76+
77+ switch {
78+ case strings.HasPrefix(path, "/rendering/status"):
79+ return s.apiHandler.HandleRenderingStatus(cfg)
80+ case strings.HasPrefix(path, "/config"):
81+ return s.apiHandler.HandleConfig(cfg)
82+ case strings.HasPrefix(path, "/history/recent"):
83+ return s.apiHandler.HandleHistoryRecent(u)
84+ case strings.HasPrefix(path, "/history/search"):
85+ return s.apiHandler.HandleHistorySearch(u)
86+ case strings.HasPrefix(path, "/history/stats"):
87+ return s.apiHandler.HandleHistoryStats()
88+ default:
89+ return s.apiHandler.HandleDefault()
90+ }
91+}
92+
93+// handleAsset serves static assets from embedded filesystem
94+func (s *SchemeHandler) handleAsset(u *neturl.URL) (string, []byte, bool) {
95+ // Resolve target path inside embed FS
96+ var rel string
97+ if u.Opaque == constants.HomepagePath || (u.Host == constants.HomepagePath && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) {
98+ rel = constants.IndexHTML
99+ } else {
100+ host := u.Host
101+ p := strings.TrimPrefix(u.Path, "/")
102+ switch {
103+ case host == "app" && p == "":
104+ rel = constants.IndexHTML
105+ case host == "app":
106+ rel = p
107+ case host == constants.HomepagePath && p != "":
108+ // dumb://homepage/<asset>
109+ rel = p
110+ case p != "":
111+ rel = p
112+ default:
113+ rel = constants.IndexHTML
114+ }
115+ }
116+
117+ // Special-case homepage favicon: map .ico request to embedded SVG file
118+ if (u.Host == constants.HomepagePath || u.Opaque == constants.HomepagePath) && strings.EqualFold(rel, "favicon.ico") {
119+ log.Printf("[scheme] asset: rel=%s (host=%s path=%s) → mapping to favicon.svg", rel, u.Host, u.Path)
120+ data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", "favicon.svg")))
121+ if rerr == nil {
122+ return "image/svg+xml", data, true
123+ }
124+ }
125+
126+ log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path)
127+ data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel)))
128+ if rerr != nil {
129+ log.Printf("[scheme] not found: %s", rel)
130+ return "", nil, false
131+ }
132+
133+ // Determine mime type
134+ mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel)))
135+ if mt == "" {
136+ // Fallbacks
137+ switch strings.ToLower(filepath.Ext(rel)) {
138+ case ".js":
139+ mt = "application/javascript"
140+ case ".css":
141+ mt = "text/css"
142+ case ".svg":
143+ mt = "image/svg+xml"
144+ case ".ico":
145+ mt = "image/x-icon"
146+ default:
147+ mt = "text/html; charset=utf-8"
148+ }
149+ }
150+ return mt, data, true
151+}
@@ -0,0 +1,151 @@
1+package api
2+
3+import (
4+ "embed"
5+ "log"
6+ "mime"
7+ neturl "net/url"
8+ "path/filepath"
9+ "strings"
10+
11+ "github.com/bnema/dumber/internal/app/constants"
12+ "github.com/bnema/dumber/internal/config"
13+ "github.com/bnema/dumber/services"
14+)
15+
16+// SchemeHandler handles custom dumb:// scheme resolution
17+type SchemeHandler struct {
18+ assets embed.FS
19+ apiHandler *Handler
20+ parserService *services.ParserService
21+ browserService *services.BrowserService
22+}
23+
24+// NewSchemeHandler creates a new scheme handler
25+func NewSchemeHandler(
26+ assets embed.FS,
27+ parserService *services.ParserService,
28+ browserService *services.BrowserService,
29+) *SchemeHandler {
30+ return &SchemeHandler{
31+ assets: assets,
32+ apiHandler: NewHandler(browserService),
33+ parserService: parserService,
34+ browserService: browserService,
35+ }
36+}
37+
38+// Handle processes dumb:// scheme requests
39+func (s *SchemeHandler) Handle(uri string, cfg *config.Config) (string, []byte, bool) {
40+ log.Printf("[scheme] request: %s", uri)
41+ // Known forms:
42+ // - dumb://homepage or dumb:homepage → index.html
43+ // - dumb://app/index.html, dumb://app/<path> → serve from frontend/dist/<path>
44+ // - dumb://<anything> without path → index.html
45+ u, err := neturl.Parse(uri)
46+ if err != nil || u.Scheme != "dumb" {
47+ return "", nil, false
48+ }
49+
50+ // API routes
51+ if u.Host == "api" || strings.HasPrefix(u.Opaque, "api") || strings.HasPrefix(u.Path, "/api") {
52+ return s.handleAPIRoute(u, cfg)
53+ }
54+
55+ // Static assets
56+ return s.handleAsset(u)
57+}
58+
59+// handleAPIRoute processes API requests
60+func (s *SchemeHandler) handleAPIRoute(u *neturl.URL, cfg *config.Config) (string, []byte, bool) {
61+ // Normalize path for opaque or hierarchical forms
62+ path := u.Path
63+ if path == "" && u.Opaque != "" {
64+ // e.g., dumb:api/config or dumb:api/history/recent
65+ parts := strings.SplitN(u.Opaque, ":", constants.ColonSplitParts)
66+ if len(parts) == constants.ColonSplitParts {
67+ path = "/" + parts[1]
68+ }
69+ }
70+ if strings.HasPrefix(path, "/api/") {
71+ path = strings.TrimPrefix(path, "/api")
72+ }
73+ if path == "/api" {
74+ path = "/"
75+ }
76+
77+ switch {
78+ case strings.HasPrefix(path, "/rendering/status"):
79+ return s.apiHandler.HandleRenderingStatus(cfg)
80+ case strings.HasPrefix(path, "/config"):
81+ return s.apiHandler.HandleConfig(cfg)
82+ case strings.HasPrefix(path, "/history/recent"):
83+ return s.apiHandler.HandleHistoryRecent(u)
84+ case strings.HasPrefix(path, "/history/search"):
85+ return s.apiHandler.HandleHistorySearch(u)
86+ case strings.HasPrefix(path, "/history/stats"):
87+ return s.apiHandler.HandleHistoryStats()
88+ default:
89+ return s.apiHandler.HandleDefault()
90+ }
91+}
92+
93+// handleAsset serves static assets from embedded filesystem
94+func (s *SchemeHandler) handleAsset(u *neturl.URL) (string, []byte, bool) {
95+ // Resolve target path inside embed FS
96+ var rel string
97+ if u.Opaque == constants.HomepagePath || (u.Host == constants.HomepagePath && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) {
98+ rel = constants.IndexHTML
99+ } else {
100+ host := u.Host
101+ p := strings.TrimPrefix(u.Path, "/")
102+ switch {
103+ case host == "app" && p == "":
104+ rel = constants.IndexHTML
105+ case host == "app":
106+ rel = p
107+ case host == constants.HomepagePath && p != "":
108+ // dumb://homepage/<asset>
109+ rel = p
110+ case p != "":
111+ rel = p
112+ default:
113+ rel = constants.IndexHTML
114+ }
115+ }
116+
117+ // Special-case homepage favicon: map .ico request to embedded SVG file
118+ if (u.Host == constants.HomepagePath || u.Opaque == constants.HomepagePath) && strings.EqualFold(rel, "favicon.ico") {
119+ log.Printf("[scheme] asset: rel=%s (host=%s path=%s) → mapping to favicon.svg", rel, u.Host, u.Path)
120+ data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", "favicon.svg")))
121+ if rerr == nil {
122+ return "image/svg+xml", data, true
123+ }
124+ }
125+
126+ log.Printf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path)
127+ data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("frontend", "dist", rel)))
128+ if rerr != nil {
129+ log.Printf("[scheme] not found: %s", rel)
130+ return "", nil, false
131+ }
132+
133+ // Determine mime type
134+ mt := mime.TypeByExtension(strings.ToLower(filepath.Ext(rel)))
135+ if mt == "" {
136+ // Fallbacks
137+ switch strings.ToLower(filepath.Ext(rel)) {
138+ case ".js":
139+ mt = "application/javascript"
140+ case ".css":
141+ mt = "text/css"
142+ case ".svg":
143+ mt = "image/svg+xml"
144+ case ".ico":
145+ mt = "image/x-icon"
146+ default:
147+ mt = "text/html; charset=utf-8"
148+ }
149+ }
150+ return mt, data, true
151+}
A internal/app/browser/browser.go +220 -0
@@ -0,0 +1,220 @@
1+package browser
2+
3+import (
4+ "database/sql"
5+ "embed"
6+ "log"
7+ "os"
8+ "os/signal"
9+ "runtime"
10+ "syscall"
11+
12+ "github.com/bnema/dumber/internal/app/api"
13+ "github.com/bnema/dumber/internal/app/control"
14+ "github.com/bnema/dumber/internal/app/environment"
15+ "github.com/bnema/dumber/internal/app/messaging"
16+ "github.com/bnema/dumber/internal/config"
17+ "github.com/bnema/dumber/internal/db"
18+ "github.com/bnema/dumber/internal/logging"
19+ "github.com/bnema/dumber/pkg/webkit"
20+ "github.com/bnema/dumber/services"
21+)
22+
23+// BrowserApp represents the main browser application
24+type BrowserApp struct {
25+ version string
26+ commit string
27+ buildDate string
28+ assets embed.FS
29+
30+ // Core components
31+ config *config.Config
32+ database *sql.DB
33+ queries *db.Queries
34+
35+ // Services
36+ parserService *services.ParserService
37+ browserService *services.BrowserService
38+
39+ // WebView and controllers
40+ webView *webkit.WebView
41+ zoomController *control.ZoomController
42+ navigationController *control.NavigationController
43+ clipboardController *control.ClipboardController
44+
45+ // Handlers
46+ schemeHandler *api.SchemeHandler
47+ messageHandler *messaging.Handler
48+ shortcutHandler *ShortcutHandler
49+}
50+
51+// Run starts the browser application
52+func Run(assets embed.FS, version, commit, buildDate string) {
53+ log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable())
54+
55+ app := &BrowserApp{
56+ version: version,
57+ commit: commit,
58+ buildDate: buildDate,
59+ assets: assets,
60+ }
61+
62+ if err := app.Initialize(); err != nil {
63+ log.Printf("Failed to initialize browser: %v", err)
64+ if webkit.IsNativeAvailable() {
65+ runtime.UnlockOSThread()
66+ }
67+ os.Exit(1)
68+ }
69+
70+ app.Run()
71+}
72+
73+// Initialize sets up all browser components
74+func (app *BrowserApp) Initialize() error {
75+ // Setup media pipeline hardening
76+ environment.SetupMediaPipeline()
77+
78+ // Initialize configuration
79+ if err := config.Init(); err != nil {
80+ return err
81+ }
82+ app.config = config.Get()
83+
84+ // Apply environment configurations
85+ environment.ApplyCodecConfiguration(app.config.CodecPreferences)
86+
87+ // Initialize logging
88+ if err := logging.Init(
89+ app.config.Logging.LogDir,
90+ app.config.Logging.Level,
91+ app.config.Logging.Format,
92+ app.config.Logging.EnableFileLog,
93+ app.config.Logging.MaxSize,
94+ app.config.Logging.MaxBackups,
95+ app.config.Logging.MaxAge,
96+ app.config.Logging.Compress,
97+ ); err != nil {
98+ log.Printf("Warning: failed to initialize logging: %v", err)
99+ }
100+
101+ // Setup crash handler
102+ logging.SetupCrashHandler()
103+
104+ // Setup output capture if configured
105+ if err := app.setupOutputCapture(); err != nil {
106+ log.Printf("Warning: failed to setup output capture: %v", err)
107+ }
108+
109+ // Initialize WebKit log capture if configured
110+ if app.config.Logging.CaptureCOutput {
111+ if err := webkit.InitWebKitLogCapture(); err != nil {
112+ log.Printf("Warning: failed to initialize WebKit log capture: %v", err)
113+ } else {
114+ defer webkit.StopWebKitLogCapture()
115+ }
116+ }
117+
118+ log.Printf("Config initialized")
119+
120+ // Detect keyboard layout
121+ environment.DetectAndSetKeyboardLocale()
122+
123+ // Initialize database
124+ database, err := db.InitDB(app.config.Database.Path)
125+ if err != nil {
126+ return err
127+ }
128+ app.database = database
129+ app.queries = db.New(database)
130+ log.Printf("Database opened at %s", app.config.Database.Path)
131+
132+ // Initialize services
133+ app.parserService = services.NewParserService(app.config, app.queries)
134+ app.browserService = services.NewBrowserService(app.config, app.queries)
135+
136+ // Initialize handlers
137+ app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService)
138+ app.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
139+
140+ return nil
141+}
142+
143+// Run executes the main browser loop
144+func (app *BrowserApp) Run() {
145+ defer app.cleanup()
146+ defer logging.SetupPanicRecovery()
147+
148+ // Register custom scheme resolver
149+ webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) {
150+ return app.schemeHandler.Handle(uri, app.config)
151+ })
152+
153+ // Create and setup WebView
154+ if err := app.createWebView(); err != nil {
155+ log.Printf("Warning: failed to create WebView: %v", err)
156+ return
157+ }
158+
159+ // Handle browse command if present
160+ app.navigationController.HandleBrowseCommand()
161+
162+ // Setup signal handling
163+ app.setupSignalHandling()
164+
165+ // Run main loop
166+ app.runMainLoop()
167+}
168+
169+// cleanup handles cleanup on shutdown
170+func (app *BrowserApp) cleanup() {
171+ if app.database != nil {
172+ if closeErr := app.database.Close(); closeErr != nil {
173+ log.Printf("Warning: failed to close database: %v", closeErr)
174+ }
175+ }
176+}
177+
178+// setupOutputCapture initializes stdout/stderr capture if configured
179+func (app *BrowserApp) setupOutputCapture() error {
180+ if app.config.Logging.CaptureStdout || app.config.Logging.CaptureStderr {
181+ log.Printf("Warning: stdout/stderr capture is experimental and may interfere with normal operations")
182+ if logger := logging.GetLogger(); logger != nil {
183+ outputCapture := logging.NewOutputCapture(logger)
184+ if err := outputCapture.Start(); err != nil {
185+ return err
186+ }
187+ // Note: defer outputCapture.Stop() should be handled by the caller
188+ }
189+ }
190+ return nil
191+}
192+
193+// setupSignalHandling configures graceful shutdown on signals
194+func (app *BrowserApp) setupSignalHandling() {
195+ sigChan := make(chan os.Signal, 1)
196+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
197+
198+ // Handle signals in a goroutine to quit the main loop
199+ go func() {
200+ sig := <-sigChan
201+ log.Printf("Received signal %v - shutting down gracefully", sig)
202+ webkit.QuitMainLoop()
203+ }()
204+}
205+
206+// runMainLoop starts the appropriate main loop based on WebKit availability
207+func (app *BrowserApp) runMainLoop() {
208+ if webkit.IsNativeAvailable() {
209+ log.Printf("Entering GTK main loop…")
210+ webkit.RunMainLoop()
211+ log.Printf("GTK main loop exited")
212+ } else {
213+ log.Printf("Not entering GUI loop (non-CGO build)")
214+ // In non-CGO mode, just wait for signals
215+ sigChan := make(chan os.Signal, 1)
216+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
217+ sig := <-sigChan
218+ log.Printf("Received signal %v - exiting", sig)
219+ }
220+}
@@ -0,0 +1,220 @@
1+package browser
2+
3+import (
4+ "database/sql"
5+ "embed"
6+ "log"
7+ "os"
8+ "os/signal"
9+ "runtime"
10+ "syscall"
11+
12+ "github.com/bnema/dumber/internal/app/api"
13+ "github.com/bnema/dumber/internal/app/control"
14+ "github.com/bnema/dumber/internal/app/environment"
15+ "github.com/bnema/dumber/internal/app/messaging"
16+ "github.com/bnema/dumber/internal/config"
17+ "github.com/bnema/dumber/internal/db"
18+ "github.com/bnema/dumber/internal/logging"
19+ "github.com/bnema/dumber/pkg/webkit"
20+ "github.com/bnema/dumber/services"
21+)
22+
23+// BrowserApp represents the main browser application
24+type BrowserApp struct {
25+ version string
26+ commit string
27+ buildDate string
28+ assets embed.FS
29+
30+ // Core components
31+ config *config.Config
32+ database *sql.DB
33+ queries *db.Queries
34+
35+ // Services
36+ parserService *services.ParserService
37+ browserService *services.BrowserService
38+
39+ // WebView and controllers
40+ webView *webkit.WebView
41+ zoomController *control.ZoomController
42+ navigationController *control.NavigationController
43+ clipboardController *control.ClipboardController
44+
45+ // Handlers
46+ schemeHandler *api.SchemeHandler
47+ messageHandler *messaging.Handler
48+ shortcutHandler *ShortcutHandler
49+}
50+
51+// Run starts the browser application
52+func Run(assets embed.FS, version, commit, buildDate string) {
53+ log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable())
54+
55+ app := &BrowserApp{
56+ version: version,
57+ commit: commit,
58+ buildDate: buildDate,
59+ assets: assets,
60+ }
61+
62+ if err := app.Initialize(); err != nil {
63+ log.Printf("Failed to initialize browser: %v", err)
64+ if webkit.IsNativeAvailable() {
65+ runtime.UnlockOSThread()
66+ }
67+ os.Exit(1)
68+ }
69+
70+ app.Run()
71+}
72+
73+// Initialize sets up all browser components
74+func (app *BrowserApp) Initialize() error {
75+ // Setup media pipeline hardening
76+ environment.SetupMediaPipeline()
77+
78+ // Initialize configuration
79+ if err := config.Init(); err != nil {
80+ return err
81+ }
82+ app.config = config.Get()
83+
84+ // Apply environment configurations
85+ environment.ApplyCodecConfiguration(app.config.CodecPreferences)
86+
87+ // Initialize logging
88+ if err := logging.Init(
89+ app.config.Logging.LogDir,
90+ app.config.Logging.Level,
91+ app.config.Logging.Format,
92+ app.config.Logging.EnableFileLog,
93+ app.config.Logging.MaxSize,
94+ app.config.Logging.MaxBackups,
95+ app.config.Logging.MaxAge,
96+ app.config.Logging.Compress,
97+ ); err != nil {
98+ log.Printf("Warning: failed to initialize logging: %v", err)
99+ }
100+
101+ // Setup crash handler
102+ logging.SetupCrashHandler()
103+
104+ // Setup output capture if configured
105+ if err := app.setupOutputCapture(); err != nil {
106+ log.Printf("Warning: failed to setup output capture: %v", err)
107+ }
108+
109+ // Initialize WebKit log capture if configured
110+ if app.config.Logging.CaptureCOutput {
111+ if err := webkit.InitWebKitLogCapture(); err != nil {
112+ log.Printf("Warning: failed to initialize WebKit log capture: %v", err)
113+ } else {
114+ defer webkit.StopWebKitLogCapture()
115+ }
116+ }
117+
118+ log.Printf("Config initialized")
119+
120+ // Detect keyboard layout
121+ environment.DetectAndSetKeyboardLocale()
122+
123+ // Initialize database
124+ database, err := db.InitDB(app.config.Database.Path)
125+ if err != nil {
126+ return err
127+ }
128+ app.database = database
129+ app.queries = db.New(database)
130+ log.Printf("Database opened at %s", app.config.Database.Path)
131+
132+ // Initialize services
133+ app.parserService = services.NewParserService(app.config, app.queries)
134+ app.browserService = services.NewBrowserService(app.config, app.queries)
135+
136+ // Initialize handlers
137+ app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService)
138+ app.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
139+
140+ return nil
141+}
142+
143+// Run executes the main browser loop
144+func (app *BrowserApp) Run() {
145+ defer app.cleanup()
146+ defer logging.SetupPanicRecovery()
147+
148+ // Register custom scheme resolver
149+ webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) {
150+ return app.schemeHandler.Handle(uri, app.config)
151+ })
152+
153+ // Create and setup WebView
154+ if err := app.createWebView(); err != nil {
155+ log.Printf("Warning: failed to create WebView: %v", err)
156+ return
157+ }
158+
159+ // Handle browse command if present
160+ app.navigationController.HandleBrowseCommand()
161+
162+ // Setup signal handling
163+ app.setupSignalHandling()
164+
165+ // Run main loop
166+ app.runMainLoop()
167+}
168+
169+// cleanup handles cleanup on shutdown
170+func (app *BrowserApp) cleanup() {
171+ if app.database != nil {
172+ if closeErr := app.database.Close(); closeErr != nil {
173+ log.Printf("Warning: failed to close database: %v", closeErr)
174+ }
175+ }
176+}
177+
178+// setupOutputCapture initializes stdout/stderr capture if configured
179+func (app *BrowserApp) setupOutputCapture() error {
180+ if app.config.Logging.CaptureStdout || app.config.Logging.CaptureStderr {
181+ log.Printf("Warning: stdout/stderr capture is experimental and may interfere with normal operations")
182+ if logger := logging.GetLogger(); logger != nil {
183+ outputCapture := logging.NewOutputCapture(logger)
184+ if err := outputCapture.Start(); err != nil {
185+ return err
186+ }
187+ // Note: defer outputCapture.Stop() should be handled by the caller
188+ }
189+ }
190+ return nil
191+}
192+
193+// setupSignalHandling configures graceful shutdown on signals
194+func (app *BrowserApp) setupSignalHandling() {
195+ sigChan := make(chan os.Signal, 1)
196+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
197+
198+ // Handle signals in a goroutine to quit the main loop
199+ go func() {
200+ sig := <-sigChan
201+ log.Printf("Received signal %v - shutting down gracefully", sig)
202+ webkit.QuitMainLoop()
203+ }()
204+}
205+
206+// runMainLoop starts the appropriate main loop based on WebKit availability
207+func (app *BrowserApp) runMainLoop() {
208+ if webkit.IsNativeAvailable() {
209+ log.Printf("Entering GTK main loop…")
210+ webkit.RunMainLoop()
211+ log.Printf("GTK main loop exited")
212+ } else {
213+ log.Printf("Not entering GUI loop (non-CGO build)")
214+ // In non-CGO mode, just wait for signals
215+ sigChan := make(chan os.Signal, 1)
216+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
217+ sig := <-sigChan
218+ log.Printf("Received signal %v - exiting", sig)
219+ }
220+}
A internal/app/browser/shortcuts.go +60 -0
@@ -0,0 +1,60 @@
1+package browser
2+
3+import (
4+ "log"
5+
6+ "github.com/bnema/dumber/internal/app/control"
7+ "github.com/bnema/dumber/pkg/webkit"
8+)
9+
10+// ShortcutHandler manages keyboard shortcuts for the browser
11+type ShortcutHandler struct {
12+ webView *webkit.WebView
13+ clipboardController *control.ClipboardController
14+}
15+
16+// NewShortcutHandler creates a new shortcut handler
17+func NewShortcutHandler(webView *webkit.WebView, clipboardController *control.ClipboardController) *ShortcutHandler {
18+ return &ShortcutHandler{
19+ webView: webView,
20+ clipboardController: clipboardController,
21+ }
22+}
23+
24+// RegisterShortcuts registers all keyboard shortcuts
25+func (s *ShortcutHandler) RegisterShortcuts() {
26+ // DevTools
27+ _ = s.webView.RegisterKeyboardShortcut("F12", func() {
28+ log.Printf("Shortcut: F12 (devtools)")
29+ _ = s.webView.ShowDevTools()
30+ })
31+
32+ // Omnibox (Ctrl+L): rely on injected script listener
33+ _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+l", func() {
34+ log.Printf("Shortcut: Omnibox toggle")
35+ _ = s.webView.InjectScript("window.__dumber_toggle && window.__dumber_toggle()")
36+ })
37+
38+ // Copy URL (Ctrl+Shift+C)
39+ _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() {
40+ s.clipboardController.CopyCurrentURL()
41+ })
42+
43+ // Page refresh shortcuts
44+ _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", func() {
45+ log.Printf("Shortcut: Reload page")
46+ _ = s.webView.Reload()
47+ })
48+
49+ _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+r", func() {
50+ log.Printf("Shortcut: Hard reload page")
51+ _ = s.webView.ReloadBypassCache()
52+ })
53+
54+ _ = s.webView.RegisterKeyboardShortcut("F5", func() {
55+ log.Printf("Shortcut: F5 reload")
56+ _ = s.webView.Reload()
57+ })
58+
59+ // Zoom handled natively in webkit package (built-in shortcuts)
60+}
@@ -0,0 +1,60 @@
1+package browser
2+
3+import (
4+ "log"
5+
6+ "github.com/bnema/dumber/internal/app/control"
7+ "github.com/bnema/dumber/pkg/webkit"
8+)
9+
10+// ShortcutHandler manages keyboard shortcuts for the browser
11+type ShortcutHandler struct {
12+ webView *webkit.WebView
13+ clipboardController *control.ClipboardController
14+}
15+
16+// NewShortcutHandler creates a new shortcut handler
17+func NewShortcutHandler(webView *webkit.WebView, clipboardController *control.ClipboardController) *ShortcutHandler {
18+ return &ShortcutHandler{
19+ webView: webView,
20+ clipboardController: clipboardController,
21+ }
22+}
23+
24+// RegisterShortcuts registers all keyboard shortcuts
25+func (s *ShortcutHandler) RegisterShortcuts() {
26+ // DevTools
27+ _ = s.webView.RegisterKeyboardShortcut("F12", func() {
28+ log.Printf("Shortcut: F12 (devtools)")
29+ _ = s.webView.ShowDevTools()
30+ })
31+
32+ // Omnibox (Ctrl+L): rely on injected script listener
33+ _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+l", func() {
34+ log.Printf("Shortcut: Omnibox toggle")
35+ _ = s.webView.InjectScript("window.__dumber_toggle && window.__dumber_toggle()")
36+ })
37+
38+ // Copy URL (Ctrl+Shift+C)
39+ _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+c", func() {
40+ s.clipboardController.CopyCurrentURL()
41+ })
42+
43+ // Page refresh shortcuts
44+ _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", func() {
45+ log.Printf("Shortcut: Reload page")
46+ _ = s.webView.Reload()
47+ })
48+
49+ _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+r", func() {
50+ log.Printf("Shortcut: Hard reload page")
51+ _ = s.webView.ReloadBypassCache()
52+ })
53+
54+ _ = s.webView.RegisterKeyboardShortcut("F5", func() {
55+ log.Printf("Shortcut: F5 reload")
56+ _ = s.webView.Reload()
57+ })
58+
59+ // Zoom handled natively in webkit package (built-in shortcuts)
60+}
A internal/app/browser/webview.go +152 -0
@@ -0,0 +1,152 @@
1+package browser
2+
3+import (
4+ "context"
5+ "log"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+
10+ "github.com/bnema/dumber/internal/app/constants"
11+ "github.com/bnema/dumber/internal/app/control"
12+ "github.com/bnema/dumber/internal/app/environment"
13+ "github.com/bnema/dumber/internal/config"
14+ "github.com/bnema/dumber/pkg/webkit"
15+)
16+
17+// buildMemoryConfig converts config.WebkitMemoryConfig to webkit.MemoryConfig
18+func buildMemoryConfig(cfg config.WebkitMemoryConfig) webkit.MemoryConfig {
19+ mc := webkit.MemoryConfig{
20+ MemoryLimitMB: cfg.MemoryLimitMB,
21+ ConservativeThreshold: cfg.ConservativeThreshold,
22+ StrictThreshold: cfg.StrictThreshold,
23+ KillThreshold: cfg.KillThreshold,
24+ PollIntervalSeconds: cfg.PollIntervalSeconds,
25+ EnableGCInterval: cfg.EnableGCInterval,
26+ ProcessRecycleThreshold: cfg.ProcessRecycleThreshold,
27+ EnablePageCache: cfg.EnablePageCache,
28+ EnableMemoryMonitoring: cfg.EnableMemoryMonitoring,
29+ }
30+
31+ // Convert string cache model to webkit constant
32+ switch strings.ToLower(cfg.CacheModel) {
33+ case "document_viewer", "documentviewer", "doc":
34+ mc.CacheModel = webkit.CacheModelDocumentViewer
35+ case "primary_web_browser", "primary", "primarywebbrowser":
36+ mc.CacheModel = webkit.CacheModelPrimaryWebBrowser
37+ default:
38+ mc.CacheModel = webkit.CacheModelWebBrowser
39+ }
40+
41+ return mc
42+}
43+
44+// createWebView creates and configures the WebView
45+func (app *BrowserApp) createWebView() error {
46+ log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
47+ dataDir, _ := config.GetDataDir()
48+ stateDir, _ := config.GetStateDir()
49+ webkitData := filepath.Join(dataDir, "webkit")
50+ webkitCache := filepath.Join(stateDir, "webkit-cache")
51+ _ = os.MkdirAll(webkitData, constants.DirPerm)
52+ _ = os.MkdirAll(webkitCache, constants.DirPerm)
53+
54+ view, err := webkit.NewWebView(&webkit.Config{
55+ InitialURL: "dumb://homepage",
56+ ZoomDefault: 1.0,
57+ EnableDeveloperExtras: true,
58+ DataDir: webkitData,
59+ CacheDir: webkitCache,
60+ DefaultSansFont: app.config.Appearance.SansFont,
61+ DefaultSerifFont: app.config.Appearance.SerifFont,
62+ DefaultMonospaceFont: app.config.Appearance.MonospaceFont,
63+ DefaultFontSize: app.config.Appearance.DefaultFontSize,
64+ Rendering: webkit.RenderingConfig{Mode: string(app.config.RenderingMode)},
65+ VideoAcceleration: webkit.VideoAccelerationConfig{
66+ EnableVAAPI: app.config.VideoAcceleration.EnableVAAPI,
67+ AutoDetectGPU: app.config.VideoAcceleration.AutoDetectGPU,
68+ VAAPIDriverName: app.config.VideoAcceleration.VAAPIDriverName,
69+ EnableAllDrivers: app.config.VideoAcceleration.EnableAllDrivers,
70+ LegacyVAAPI: app.config.VideoAcceleration.LegacyVAAPI,
71+ },
72+ Memory: buildMemoryConfig(app.config.WebkitMemory),
73+ CodecPreferences: webkit.CodecPreferencesConfig{
74+ PreferredCodecs: strings.Split(app.config.CodecPreferences.PreferredCodecs, ","),
75+ BlockedCodecs: environment.BuildBlockedCodecsList(app.config.CodecPreferences),
76+ ForceAV1: app.config.CodecPreferences.ForceAV1,
77+ CustomUserAgent: app.config.CodecPreferences.CustomUserAgent,
78+ DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl,
79+ },
80+ })
81+
82+ if err != nil {
83+ return err
84+ }
85+
86+ app.webView = view
87+ app.setupWebViewIntegration()
88+ app.setupWebViewHandlers()
89+ app.setupControllers()
90+ app.setupKeyboardShortcuts()
91+
92+ // Apply initial zoom and show window
93+ app.zoomController.ApplyInitialZoom()
94+
95+ log.Printf("Showing WebView window…")
96+ if err := view.Show(); err != nil {
97+ log.Printf("Warning: failed to show WebView: %v", err)
98+ } else if !webkit.IsNativeAvailable() {
99+ log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
100+ }
101+
102+ return nil
103+}
104+
105+// setupWebViewIntegration connects the WebView to browser services
106+func (app *BrowserApp) setupWebViewIntegration() {
107+ app.browserService.AttachWebView(app.webView)
108+ // Use native window as title updater
109+ if win := app.webView.Window(); win != nil {
110+ app.browserService.SetWindowTitleUpdater(win)
111+ }
112+}
113+
114+// setupWebViewHandlers configures WebView event handlers
115+func (app *BrowserApp) setupWebViewHandlers() {
116+ // Persist page titles to DB when they change
117+ app.webView.RegisterTitleChangedHandler(func(title string) {
118+ ctx := context.Background()
119+ url := app.webView.GetCurrentURL()
120+ if url != "" && title != "" {
121+ if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil {
122+ log.Printf("Warning: failed to update page title: %v", err)
123+ }
124+ }
125+ })
126+
127+ // Set WebView reference for message handler and register script messages
128+ app.messageHandler.SetWebView(app.webView)
129+ app.webView.RegisterScriptMessageHandler(app.messageHandler.Handle)
130+}
131+
132+// setupControllers initializes and configures controller objects
133+func (app *BrowserApp) setupControllers() {
134+ // Initialize controllers
135+ app.zoomController = control.NewZoomController(app.browserService, app.webView)
136+ app.navigationController = control.NewNavigationController(
137+ app.parserService,
138+ app.browserService,
139+ app.webView,
140+ app.zoomController,
141+ )
142+ app.clipboardController = control.NewClipboardController(app.webView)
143+
144+ // Register controller handlers
145+ app.zoomController.RegisterHandlers()
146+}
147+
148+// setupKeyboardShortcuts registers keyboard shortcuts
149+func (app *BrowserApp) setupKeyboardShortcuts() {
150+ app.shortcutHandler = NewShortcutHandler(app.webView, app.clipboardController)
151+ app.shortcutHandler.RegisterShortcuts()
152+}
@@ -0,0 +1,152 @@
1+package browser
2+
3+import (
4+ "context"
5+ "log"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+
10+ "github.com/bnema/dumber/internal/app/constants"
11+ "github.com/bnema/dumber/internal/app/control"
12+ "github.com/bnema/dumber/internal/app/environment"
13+ "github.com/bnema/dumber/internal/config"
14+ "github.com/bnema/dumber/pkg/webkit"
15+)
16+
17+// buildMemoryConfig converts config.WebkitMemoryConfig to webkit.MemoryConfig
18+func buildMemoryConfig(cfg config.WebkitMemoryConfig) webkit.MemoryConfig {
19+ mc := webkit.MemoryConfig{
20+ MemoryLimitMB: cfg.MemoryLimitMB,
21+ ConservativeThreshold: cfg.ConservativeThreshold,
22+ StrictThreshold: cfg.StrictThreshold,
23+ KillThreshold: cfg.KillThreshold,
24+ PollIntervalSeconds: cfg.PollIntervalSeconds,
25+ EnableGCInterval: cfg.EnableGCInterval,
26+ ProcessRecycleThreshold: cfg.ProcessRecycleThreshold,
27+ EnablePageCache: cfg.EnablePageCache,
28+ EnableMemoryMonitoring: cfg.EnableMemoryMonitoring,
29+ }
30+
31+ // Convert string cache model to webkit constant
32+ switch strings.ToLower(cfg.CacheModel) {
33+ case "document_viewer", "documentviewer", "doc":
34+ mc.CacheModel = webkit.CacheModelDocumentViewer
35+ case "primary_web_browser", "primary", "primarywebbrowser":
36+ mc.CacheModel = webkit.CacheModelPrimaryWebBrowser
37+ default:
38+ mc.CacheModel = webkit.CacheModelWebBrowser
39+ }
40+
41+ return mc
42+}
43+
44+// createWebView creates and configures the WebView
45+func (app *BrowserApp) createWebView() error {
46+ log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
47+ dataDir, _ := config.GetDataDir()
48+ stateDir, _ := config.GetStateDir()
49+ webkitData := filepath.Join(dataDir, "webkit")
50+ webkitCache := filepath.Join(stateDir, "webkit-cache")
51+ _ = os.MkdirAll(webkitData, constants.DirPerm)
52+ _ = os.MkdirAll(webkitCache, constants.DirPerm)
53+
54+ view, err := webkit.NewWebView(&webkit.Config{
55+ InitialURL: "dumb://homepage",
56+ ZoomDefault: 1.0,
57+ EnableDeveloperExtras: true,
58+ DataDir: webkitData,
59+ CacheDir: webkitCache,
60+ DefaultSansFont: app.config.Appearance.SansFont,
61+ DefaultSerifFont: app.config.Appearance.SerifFont,
62+ DefaultMonospaceFont: app.config.Appearance.MonospaceFont,
63+ DefaultFontSize: app.config.Appearance.DefaultFontSize,
64+ Rendering: webkit.RenderingConfig{Mode: string(app.config.RenderingMode)},
65+ VideoAcceleration: webkit.VideoAccelerationConfig{
66+ EnableVAAPI: app.config.VideoAcceleration.EnableVAAPI,
67+ AutoDetectGPU: app.config.VideoAcceleration.AutoDetectGPU,
68+ VAAPIDriverName: app.config.VideoAcceleration.VAAPIDriverName,
69+ EnableAllDrivers: app.config.VideoAcceleration.EnableAllDrivers,
70+ LegacyVAAPI: app.config.VideoAcceleration.LegacyVAAPI,
71+ },
72+ Memory: buildMemoryConfig(app.config.WebkitMemory),
73+ CodecPreferences: webkit.CodecPreferencesConfig{
74+ PreferredCodecs: strings.Split(app.config.CodecPreferences.PreferredCodecs, ","),
75+ BlockedCodecs: environment.BuildBlockedCodecsList(app.config.CodecPreferences),
76+ ForceAV1: app.config.CodecPreferences.ForceAV1,
77+ CustomUserAgent: app.config.CodecPreferences.CustomUserAgent,
78+ DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl,
79+ },
80+ })
81+
82+ if err != nil {
83+ return err
84+ }
85+
86+ app.webView = view
87+ app.setupWebViewIntegration()
88+ app.setupWebViewHandlers()
89+ app.setupControllers()
90+ app.setupKeyboardShortcuts()
91+
92+ // Apply initial zoom and show window
93+ app.zoomController.ApplyInitialZoom()
94+
95+ log.Printf("Showing WebView window…")
96+ if err := view.Show(); err != nil {
97+ log.Printf("Warning: failed to show WebView: %v", err)
98+ } else if !webkit.IsNativeAvailable() {
99+ log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
100+ }
101+
102+ return nil
103+}
104+
105+// setupWebViewIntegration connects the WebView to browser services
106+func (app *BrowserApp) setupWebViewIntegration() {
107+ app.browserService.AttachWebView(app.webView)
108+ // Use native window as title updater
109+ if win := app.webView.Window(); win != nil {
110+ app.browserService.SetWindowTitleUpdater(win)
111+ }
112+}
113+
114+// setupWebViewHandlers configures WebView event handlers
115+func (app *BrowserApp) setupWebViewHandlers() {
116+ // Persist page titles to DB when they change
117+ app.webView.RegisterTitleChangedHandler(func(title string) {
118+ ctx := context.Background()
119+ url := app.webView.GetCurrentURL()
120+ if url != "" && title != "" {
121+ if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil {
122+ log.Printf("Warning: failed to update page title: %v", err)
123+ }
124+ }
125+ })
126+
127+ // Set WebView reference for message handler and register script messages
128+ app.messageHandler.SetWebView(app.webView)
129+ app.webView.RegisterScriptMessageHandler(app.messageHandler.Handle)
130+}
131+
132+// setupControllers initializes and configures controller objects
133+func (app *BrowserApp) setupControllers() {
134+ // Initialize controllers
135+ app.zoomController = control.NewZoomController(app.browserService, app.webView)
136+ app.navigationController = control.NewNavigationController(
137+ app.parserService,
138+ app.browserService,
139+ app.webView,
140+ app.zoomController,
141+ )
142+ app.clipboardController = control.NewClipboardController(app.webView)
143+
144+ // Register controller handlers
145+ app.zoomController.RegisterHandlers()
146+}
147+
148+// setupKeyboardShortcuts registers keyboard shortcuts
149+func (app *BrowserApp) setupKeyboardShortcuts() {
150+ app.shortcutHandler = NewShortcutHandler(app.webView, app.clipboardController)
151+ app.shortcutHandler.RegisterShortcuts()
152+}
A internal/app/cli/runner.go +79 -0
@@ -0,0 +1,79 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "os"
6+
7+ "github.com/bnema/dumber/internal/cli"
8+ "github.com/bnema/dumber/internal/config"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// Execute runs the CLI functionality using the existing CLI package
13+func Execute(version, commit, buildDate string) {
14+ // Initialize configuration system
15+ if err := config.Init(); err != nil {
16+ fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err)
17+ os.Exit(1)
18+ }
19+
20+ // Initialize logging system for CLI mode (file logging only, no output capture)
21+ cfg := config.Get()
22+ if err := logging.Init(
23+ cfg.Logging.LogDir,
24+ cfg.Logging.Level,
25+ cfg.Logging.Format,
26+ cfg.Logging.EnableFileLog,
27+ cfg.Logging.MaxSize,
28+ cfg.Logging.MaxBackups,
29+ cfg.Logging.MaxAge,
30+ cfg.Logging.Compress,
31+ ); err != nil {
32+ fmt.Fprintf(os.Stderr, "Warning: failed to initialize logging: %v\n", err)
33+ }
34+
35+ // Start configuration watching for live reload
36+ if err := config.Watch(); err != nil {
37+ // Don't exit on watch error, just warn
38+ fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err)
39+ }
40+
41+ rootCmd := cli.NewRootCmd(version, commit, buildDate)
42+
43+ // Handle dmenu flag at the root level for direct integration
44+ if len(os.Args) > 1 && os.Args[1] == "--dmenu" {
45+ handleDmenuMode()
46+ return
47+ }
48+
49+ // Execute the root command
50+ if err := rootCmd.Execute(); err != nil {
51+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
52+ os.Exit(1)
53+ }
54+}
55+
56+// handleDmenuMode processes dmenu-specific functionality
57+func handleDmenuMode() {
58+ // Create a temporary CLI instance to handle dmenu mode
59+ cliInstance, err := cli.NewCLI()
60+ if err != nil {
61+ fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err)
62+ os.Exit(1)
63+ }
64+ defer func() {
65+ if closeErr := cliInstance.Close(); closeErr != nil {
66+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
67+ }
68+ }()
69+
70+ // Generate dmenu options
71+ dmenuCmd := cli.NewDmenuCmd()
72+ if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil {
73+ fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err)
74+ if closeErr := cliInstance.Close(); closeErr != nil {
75+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
76+ }
77+ os.Exit(1)
78+ }
79+}
@@ -0,0 +1,79 @@
1+package cli
2+
3+import (
4+ "fmt"
5+ "os"
6+
7+ "github.com/bnema/dumber/internal/cli"
8+ "github.com/bnema/dumber/internal/config"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// Execute runs the CLI functionality using the existing CLI package
13+func Execute(version, commit, buildDate string) {
14+ // Initialize configuration system
15+ if err := config.Init(); err != nil {
16+ fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err)
17+ os.Exit(1)
18+ }
19+
20+ // Initialize logging system for CLI mode (file logging only, no output capture)
21+ cfg := config.Get()
22+ if err := logging.Init(
23+ cfg.Logging.LogDir,
24+ cfg.Logging.Level,
25+ cfg.Logging.Format,
26+ cfg.Logging.EnableFileLog,
27+ cfg.Logging.MaxSize,
28+ cfg.Logging.MaxBackups,
29+ cfg.Logging.MaxAge,
30+ cfg.Logging.Compress,
31+ ); err != nil {
32+ fmt.Fprintf(os.Stderr, "Warning: failed to initialize logging: %v\n", err)
33+ }
34+
35+ // Start configuration watching for live reload
36+ if err := config.Watch(); err != nil {
37+ // Don't exit on watch error, just warn
38+ fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err)
39+ }
40+
41+ rootCmd := cli.NewRootCmd(version, commit, buildDate)
42+
43+ // Handle dmenu flag at the root level for direct integration
44+ if len(os.Args) > 1 && os.Args[1] == "--dmenu" {
45+ handleDmenuMode()
46+ return
47+ }
48+
49+ // Execute the root command
50+ if err := rootCmd.Execute(); err != nil {
51+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
52+ os.Exit(1)
53+ }
54+}
55+
56+// handleDmenuMode processes dmenu-specific functionality
57+func handleDmenuMode() {
58+ // Create a temporary CLI instance to handle dmenu mode
59+ cliInstance, err := cli.NewCLI()
60+ if err != nil {
61+ fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err)
62+ os.Exit(1)
63+ }
64+ defer func() {
65+ if closeErr := cliInstance.Close(); closeErr != nil {
66+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
67+ }
68+ }()
69+
70+ // Generate dmenu options
71+ dmenuCmd := cli.NewDmenuCmd()
72+ if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil {
73+ fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err)
74+ if closeErr := cliInstance.Close(); closeErr != nil {
75+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
76+ }
77+ os.Exit(1)
78+ }
79+}
A internal/app/constants/constants.go +21 -0
@@ -0,0 +1,21 @@
1+// Package constants contains shared constants for the application.
2+package constants
3+
4+// MIME type and path constants
5+const (
6+ ContentTypeJSON = "application/json; charset=utf-8"
7+ HomepagePath = "homepage"
8+ IndexHTML = "index.html"
9+)
10+
11+// Parsing and utility constants
12+const (
13+ ColonSplitParts = 2 // Number of parts when splitting on colon
14+ DirPerm = 0o755 // Directory permissions
15+)
16+
17+// JavaScript hash constants (auto-generated, should not be changed)
18+const (
19+ HashGetRecentHistory = 3708519028 // BrowserService.GetRecentHistory(limit)
20+ HashGetSearchShortcuts = 4078533762 // BrowserService.GetSearchShortcuts()
21+)
@@ -0,0 +1,21 @@
1+// Package constants contains shared constants for the application.
2+package constants
3+
4+// MIME type and path constants
5+const (
6+ ContentTypeJSON = "application/json; charset=utf-8"
7+ HomepagePath = "homepage"
8+ IndexHTML = "index.html"
9+)
10+
11+// Parsing and utility constants
12+const (
13+ ColonSplitParts = 2 // Number of parts when splitting on colon
14+ DirPerm = 0o755 // Directory permissions
15+)
16+
17+// JavaScript hash constants (auto-generated, should not be changed)
18+const (
19+ HashGetRecentHistory = 3708519028 // BrowserService.GetRecentHistory(limit)
20+ HashGetSearchShortcuts = 4078533762 // BrowserService.GetSearchShortcuts()
21+)
A internal/app/control/clipboard.go +39 -0
@@ -0,0 +1,39 @@
1+package control
2+
3+import (
4+ "log"
5+
6+ "github.com/bnema/dumber/pkg/clipboard"
7+ "github.com/bnema/dumber/pkg/webkit"
8+)
9+
10+// ClipboardController handles clipboard operations
11+type ClipboardController struct {
12+ webView *webkit.WebView
13+}
14+
15+// NewClipboardController creates a new clipboard controller
16+func NewClipboardController(webView *webkit.WebView) *ClipboardController {
17+ return &ClipboardController{
18+ webView: webView,
19+ }
20+}
21+
22+// CopyCurrentURL copies the current WebView URL to the clipboard
23+func (c *ClipboardController) CopyCurrentURL() {
24+ log.Printf("Shortcut: Copy URL")
25+ currentURL := c.webView.GetCurrentURL()
26+ if currentURL == "" {
27+ log.Printf("No URL to copy")
28+ _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`)
29+ return
30+ }
31+
32+ if err := clipboard.CopyToClipboard(currentURL); err != nil {
33+ log.Printf("Failed to copy URL to clipboard: %v", err)
34+ _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`)
35+ } else {
36+ log.Printf("URL copied to clipboard: %s", currentURL)
37+ _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("URL copied to clipboard", 2000)`)
38+ }
39+}
@@ -0,0 +1,39 @@
1+package control
2+
3+import (
4+ "log"
5+
6+ "github.com/bnema/dumber/pkg/clipboard"
7+ "github.com/bnema/dumber/pkg/webkit"
8+)
9+
10+// ClipboardController handles clipboard operations
11+type ClipboardController struct {
12+ webView *webkit.WebView
13+}
14+
15+// NewClipboardController creates a new clipboard controller
16+func NewClipboardController(webView *webkit.WebView) *ClipboardController {
17+ return &ClipboardController{
18+ webView: webView,
19+ }
20+}
21+
22+// CopyCurrentURL copies the current WebView URL to the clipboard
23+func (c *ClipboardController) CopyCurrentURL() {
24+ log.Printf("Shortcut: Copy URL")
25+ currentURL := c.webView.GetCurrentURL()
26+ if currentURL == "" {
27+ log.Printf("No URL to copy")
28+ _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("No URL to copy", 2000)`)
29+ return
30+ }
31+
32+ if err := clipboard.CopyToClipboard(currentURL); err != nil {
33+ log.Printf("Failed to copy URL to clipboard: %v", err)
34+ _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("Failed to copy URL", 2000)`)
35+ } else {
36+ log.Printf("URL copied to clipboard: %s", currentURL)
37+ _ = c.webView.InjectScript(`window.__dumber_showToast && window.__dumber_showToast("URL copied to clipboard", 2000)`)
38+ }
39+}
A internal/app/control/navigation.go +79 -0
@@ -0,0 +1,79 @@
1+package control
2+
3+import (
4+ "context"
5+ "log"
6+ "os"
7+
8+ "github.com/bnema/dumber/pkg/webkit"
9+ "github.com/bnema/dumber/services"
10+)
11+
12+// NavigationController manages navigation functionality
13+type NavigationController struct {
14+ parserService *services.ParserService
15+ browserService *services.BrowserService
16+ webView *webkit.WebView
17+ zoomController *ZoomController
18+}
19+
20+// NewNavigationController creates a new navigation controller
21+func NewNavigationController(
22+ parserService *services.ParserService,
23+ browserService *services.BrowserService,
24+ webView *webkit.WebView,
25+ zoomController *ZoomController,
26+) *NavigationController {
27+ return &NavigationController{
28+ parserService: parserService,
29+ browserService: browserService,
30+ webView: webView,
31+ zoomController: zoomController,
32+ }
33+}
34+
35+// NavigateToURL parses input and navigates to the resulting URL
36+func (n *NavigationController) NavigateToURL(input string) error {
37+ ctx := context.Background()
38+ result, err := n.parserService.ParseInput(ctx, input)
39+ if err != nil {
40+ return err
41+ }
42+
43+ // Record navigation in browser service
44+ if _, navErr := n.browserService.Navigate(ctx, result.URL); navErr != nil {
45+ log.Printf("Warning: failed to navigate to %s: %v", result.URL, navErr)
46+ }
47+
48+ // Load URL in WebView
49+ if err := n.webView.LoadURL(result.URL); err != nil {
50+ return err
51+ }
52+
53+ // Apply zoom for the new URL
54+ n.zoomController.ApplyZoomForURL(result.URL)
55+
56+ return nil
57+}
58+
59+// HandleBrowseCommand processes the browse command line argument
60+func (n *NavigationController) HandleBrowseCommand() {
61+ if len(os.Args) >= 3 && os.Args[1] == "browse" {
62+ log.Printf("Browse command detected: %s", os.Args[2])
63+ ctx := context.Background()
64+ result, err := n.parserService.ParseInput(ctx, os.Args[2])
65+ if err == nil {
66+ log.Printf("Parsed input → URL: %s", result.URL)
67+ if _, navErr := n.browserService.Navigate(ctx, result.URL); navErr != nil {
68+ log.Printf("Warning: failed to navigate to %s: %v", result.URL, navErr)
69+ }
70+ if n.webView != nil {
71+ log.Printf("Loading URL in WebView: %s", result.URL)
72+ if err := n.webView.LoadURL(result.URL); err != nil {
73+ log.Printf("Warning: failed to load URL: %v", err)
74+ }
75+ n.zoomController.ApplyZoomForURL(result.URL)
76+ }
77+ }
78+ }
79+}
@@ -0,0 +1,79 @@
1+package control
2+
3+import (
4+ "context"
5+ "log"
6+ "os"
7+
8+ "github.com/bnema/dumber/pkg/webkit"
9+ "github.com/bnema/dumber/services"
10+)
11+
12+// NavigationController manages navigation functionality
13+type NavigationController struct {
14+ parserService *services.ParserService
15+ browserService *services.BrowserService
16+ webView *webkit.WebView
17+ zoomController *ZoomController
18+}
19+
20+// NewNavigationController creates a new navigation controller
21+func NewNavigationController(
22+ parserService *services.ParserService,
23+ browserService *services.BrowserService,
24+ webView *webkit.WebView,
25+ zoomController *ZoomController,
26+) *NavigationController {
27+ return &NavigationController{
28+ parserService: parserService,
29+ browserService: browserService,
30+ webView: webView,
31+ zoomController: zoomController,
32+ }
33+}
34+
35+// NavigateToURL parses input and navigates to the resulting URL
36+func (n *NavigationController) NavigateToURL(input string) error {
37+ ctx := context.Background()
38+ result, err := n.parserService.ParseInput(ctx, input)
39+ if err != nil {
40+ return err
41+ }
42+
43+ // Record navigation in browser service
44+ if _, navErr := n.browserService.Navigate(ctx, result.URL); navErr != nil {
45+ log.Printf("Warning: failed to navigate to %s: %v", result.URL, navErr)
46+ }
47+
48+ // Load URL in WebView
49+ if err := n.webView.LoadURL(result.URL); err != nil {
50+ return err
51+ }
52+
53+ // Apply zoom for the new URL
54+ n.zoomController.ApplyZoomForURL(result.URL)
55+
56+ return nil
57+}
58+
59+// HandleBrowseCommand processes the browse command line argument
60+func (n *NavigationController) HandleBrowseCommand() {
61+ if len(os.Args) >= 3 && os.Args[1] == "browse" {
62+ log.Printf("Browse command detected: %s", os.Args[2])
63+ ctx := context.Background()
64+ result, err := n.parserService.ParseInput(ctx, os.Args[2])
65+ if err == nil {
66+ log.Printf("Parsed input → URL: %s", result.URL)
67+ if _, navErr := n.browserService.Navigate(ctx, result.URL); navErr != nil {
68+ log.Printf("Warning: failed to navigate to %s: %v", result.URL, navErr)
69+ }
70+ if n.webView != nil {
71+ log.Printf("Loading URL in WebView: %s", result.URL)
72+ if err := n.webView.LoadURL(result.URL); err != nil {
73+ log.Printf("Warning: failed to load URL: %v", err)
74+ }
75+ n.zoomController.ApplyZoomForURL(result.URL)
76+ }
77+ }
78+ }
79+}
A internal/app/control/zoom.go +137 -0
@@ -0,0 +1,137 @@
1+package control
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log"
7+
8+ "github.com/bnema/dumber/pkg/webkit"
9+ "github.com/bnema/dumber/services"
10+)
11+
12+// ZoomController manages zoom functionality for the WebView
13+type ZoomController struct {
14+ currentURL string
15+ lastZoomDomain string
16+ programmaticChange bool
17+ browserService *services.BrowserService
18+ webView *webkit.WebView
19+}
20+
21+// WebViewInterface defines the interface for WebView zoom operations
22+type WebViewInterface interface {
23+ GetCurrentURL() string
24+ SetZoom(level float64) error
25+ InjectScript(script string) error
26+ RegisterZoomChangedHandler(handler func(float64))
27+ RegisterURIChangedHandler(handler func(string))
28+}
29+
30+// NewZoomController creates a new zoom controller
31+func NewZoomController(browserService *services.BrowserService, webView *webkit.WebView) *ZoomController {
32+ return &ZoomController{
33+ currentURL: "dumb://homepage",
34+ browserService: browserService,
35+ webView: webView,
36+ }
37+}
38+
39+// RegisterHandlers sets up the zoom-related event handlers
40+func (z *ZoomController) RegisterHandlers() {
41+ z.webView.RegisterZoomChangedHandler(z.handleZoomChange)
42+ z.webView.RegisterURIChangedHandler(z.handleURIChange)
43+}
44+
45+// handleURIChange responds to URL changes and applies saved zoom levels
46+func (z *ZoomController) handleURIChange(url string) {
47+ z.currentURL = url
48+ if url == "" {
49+ return
50+ }
51+ ctx := context.Background()
52+ currentDomain := services.ZoomKeyForLog(url)
53+
54+ if zoomLevel, err := z.browserService.GetZoomLevel(ctx, url); err == nil {
55+ z.programmaticChange = true
56+ if err := z.webView.SetZoom(zoomLevel); err == nil {
57+ log.Printf("[zoom] loaded %.2f for %s", zoomLevel, currentDomain)
58+
59+ // Only show toast when entering a new domain (not on first load)
60+ if currentDomain != z.lastZoomDomain && z.lastZoomDomain != "" {
61+ z.showZoomToast(zoomLevel)
62+ }
63+ z.lastZoomDomain = currentDomain
64+ }
65+ z.programmaticChange = false
66+ }
67+}
68+
69+// handleZoomChange responds to zoom level changes and persists them
70+func (z *ZoomController) handleZoomChange(level float64) {
71+ url := z.webView.GetCurrentURL()
72+ if url == "" {
73+ return
74+ }
75+ ctx := context.Background()
76+ if err := z.browserService.SetZoomLevel(ctx, url, level); err != nil {
77+ log.Printf("[zoom] failed to save level %.2f for %s: %v", level, url, err)
78+ return
79+ }
80+ key := services.ZoomKeyForLog(url)
81+ log.Printf("[zoom] saved %.2f for %s", level, key)
82+
83+ // Only show toast for user-initiated zoom changes, not programmatic ones
84+ if !z.programmaticChange {
85+ z.showZoomToast(level)
86+ }
87+}
88+
89+// showZoomToast displays a zoom level notification
90+func (z *ZoomController) showZoomToast(level float64) {
91+ js := fmt.Sprintf(`try {
92+ if (typeof window.__dumber_showZoomToast === 'function') {
93+ window.__dumber_showZoomToast(%f);
94+ } else {
95+ console.warn('[dumber] Zoom toast function not available yet');
96+ }
97+ } catch(e) {
98+ console.error('[dumber] Zoom toast error:', e);
99+ }`, level)
100+ if err := z.webView.InjectScript(js); err != nil {
101+ log.Printf("[zoom] failed to show toast: %v", err)
102+ }
103+}
104+
105+// ApplyInitialZoom sets the initial zoom level for the current URL
106+func (z *ZoomController) ApplyInitialZoom() {
107+ ctx := context.Background()
108+ url := z.webView.GetCurrentURL()
109+ if url == "" {
110+ url = "dumb://homepage"
111+ }
112+ if zoomLevel, err := z.browserService.GetZoomLevel(ctx, url); err == nil {
113+ z.programmaticChange = true
114+ if err := z.webView.SetZoom(zoomLevel); err != nil {
115+ log.Printf("Warning: failed to set initial zoom: %v", err)
116+ } else {
117+ key := services.ZoomKeyForLog(url)
118+ log.Printf("[zoom] loaded %.2f for %s", zoomLevel, key)
119+ }
120+ z.programmaticChange = false
121+ }
122+}
123+
124+// ApplyZoomForURL applies zoom for a specific URL (used for navigation)
125+func (z *ZoomController) ApplyZoomForURL(url string) {
126+ ctx := context.Background()
127+ if zoomLevel, err := z.browserService.GetZoomLevel(ctx, url); err == nil {
128+ z.programmaticChange = true
129+ if err := z.webView.SetZoom(zoomLevel); err != nil {
130+ log.Printf("Warning: failed to set zoom: %v", err)
131+ } else {
132+ key := services.ZoomKeyForLog(url)
133+ log.Printf("[zoom] loaded %.2f for %s", zoomLevel, key)
134+ }
135+ z.programmaticChange = false
136+ }
137+}
@@ -0,0 +1,137 @@
1+package control
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log"
7+
8+ "github.com/bnema/dumber/pkg/webkit"
9+ "github.com/bnema/dumber/services"
10+)
11+
12+// ZoomController manages zoom functionality for the WebView
13+type ZoomController struct {
14+ currentURL string
15+ lastZoomDomain string
16+ programmaticChange bool
17+ browserService *services.BrowserService
18+ webView *webkit.WebView
19+}
20+
21+// WebViewInterface defines the interface for WebView zoom operations
22+type WebViewInterface interface {
23+ GetCurrentURL() string
24+ SetZoom(level float64) error
25+ InjectScript(script string) error
26+ RegisterZoomChangedHandler(handler func(float64))
27+ RegisterURIChangedHandler(handler func(string))
28+}
29+
30+// NewZoomController creates a new zoom controller
31+func NewZoomController(browserService *services.BrowserService, webView *webkit.WebView) *ZoomController {
32+ return &ZoomController{
33+ currentURL: "dumb://homepage",
34+ browserService: browserService,
35+ webView: webView,
36+ }
37+}
38+
39+// RegisterHandlers sets up the zoom-related event handlers
40+func (z *ZoomController) RegisterHandlers() {
41+ z.webView.RegisterZoomChangedHandler(z.handleZoomChange)
42+ z.webView.RegisterURIChangedHandler(z.handleURIChange)
43+}
44+
45+// handleURIChange responds to URL changes and applies saved zoom levels
46+func (z *ZoomController) handleURIChange(url string) {
47+ z.currentURL = url
48+ if url == "" {
49+ return
50+ }
51+ ctx := context.Background()
52+ currentDomain := services.ZoomKeyForLog(url)
53+
54+ if zoomLevel, err := z.browserService.GetZoomLevel(ctx, url); err == nil {
55+ z.programmaticChange = true
56+ if err := z.webView.SetZoom(zoomLevel); err == nil {
57+ log.Printf("[zoom] loaded %.2f for %s", zoomLevel, currentDomain)
58+
59+ // Only show toast when entering a new domain (not on first load)
60+ if currentDomain != z.lastZoomDomain && z.lastZoomDomain != "" {
61+ z.showZoomToast(zoomLevel)
62+ }
63+ z.lastZoomDomain = currentDomain
64+ }
65+ z.programmaticChange = false
66+ }
67+}
68+
69+// handleZoomChange responds to zoom level changes and persists them
70+func (z *ZoomController) handleZoomChange(level float64) {
71+ url := z.webView.GetCurrentURL()
72+ if url == "" {
73+ return
74+ }
75+ ctx := context.Background()
76+ if err := z.browserService.SetZoomLevel(ctx, url, level); err != nil {
77+ log.Printf("[zoom] failed to save level %.2f for %s: %v", level, url, err)
78+ return
79+ }
80+ key := services.ZoomKeyForLog(url)
81+ log.Printf("[zoom] saved %.2f for %s", level, key)
82+
83+ // Only show toast for user-initiated zoom changes, not programmatic ones
84+ if !z.programmaticChange {
85+ z.showZoomToast(level)
86+ }
87+}
88+
89+// showZoomToast displays a zoom level notification
90+func (z *ZoomController) showZoomToast(level float64) {
91+ js := fmt.Sprintf(`try {
92+ if (typeof window.__dumber_showZoomToast === 'function') {
93+ window.__dumber_showZoomToast(%f);
94+ } else {
95+ console.warn('[dumber] Zoom toast function not available yet');
96+ }
97+ } catch(e) {
98+ console.error('[dumber] Zoom toast error:', e);
99+ }`, level)
100+ if err := z.webView.InjectScript(js); err != nil {
101+ log.Printf("[zoom] failed to show toast: %v", err)
102+ }
103+}
104+
105+// ApplyInitialZoom sets the initial zoom level for the current URL
106+func (z *ZoomController) ApplyInitialZoom() {
107+ ctx := context.Background()
108+ url := z.webView.GetCurrentURL()
109+ if url == "" {
110+ url = "dumb://homepage"
111+ }
112+ if zoomLevel, err := z.browserService.GetZoomLevel(ctx, url); err == nil {
113+ z.programmaticChange = true
114+ if err := z.webView.SetZoom(zoomLevel); err != nil {
115+ log.Printf("Warning: failed to set initial zoom: %v", err)
116+ } else {
117+ key := services.ZoomKeyForLog(url)
118+ log.Printf("[zoom] loaded %.2f for %s", zoomLevel, key)
119+ }
120+ z.programmaticChange = false
121+ }
122+}
123+
124+// ApplyZoomForURL applies zoom for a specific URL (used for navigation)
125+func (z *ZoomController) ApplyZoomForURL(url string) {
126+ ctx := context.Background()
127+ if zoomLevel, err := z.browserService.GetZoomLevel(ctx, url); err == nil {
128+ z.programmaticChange = true
129+ if err := z.webView.SetZoom(zoomLevel); err != nil {
130+ log.Printf("Warning: failed to set zoom: %v", err)
131+ } else {
132+ key := services.ZoomKeyForLog(url)
133+ log.Printf("[zoom] loaded %.2f for %s", zoomLevel, key)
134+ }
135+ z.programmaticChange = false
136+ }
137+}
A internal/app/environment/codec.go +140 -0
@@ -0,0 +1,140 @@
1+package environment
2+
3+import (
4+ "log"
5+ "os"
6+ "strconv"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/config"
10+)
11+
12+// ApplyCodecConfiguration applies codec preferences to environment variables and GStreamer settings
13+func ApplyCodecConfiguration(codecPrefs config.CodecConfig) {
14+ log.Printf("[codec] Applying codec preferences: preferred=%s, force_av1=%t, block_vp9=%t",
15+ codecPrefs.PreferredCodecs, codecPrefs.ForceAV1, codecPrefs.BlockVP9)
16+
17+ // Build comprehensive GStreamer plugin rankings
18+ var rankSettings []string
19+
20+ // Get existing GST_PLUGIN_FEATURE_RANK or start fresh
21+ existingRank := os.Getenv("GST_PLUGIN_FEATURE_RANK")
22+ if existingRank != "" {
23+ rankSettings = strings.Split(existingRank, ",")
24+ }
25+
26+ // AV1 decoder promotions (high priority)
27+ if codecPrefs.ForceAV1 || strings.Contains(codecPrefs.PreferredCodecs, "av1") {
28+ av1Ranks := []string{
29+ "vaav1dec:768", // VA-API AV1 decoder (highest priority)
30+ "nvav1dec:512", // NVDEC AV1 decoder
31+ "av1dec:384", // Software AV1 decoder (fallback)
32+ "avdec_av1:256", // libav AV1 decoder
33+ }
34+ rankSettings = append(rankSettings, av1Ranks...)
35+ log.Printf("[codec] Promoted AV1 decoders for maximum priority")
36+ }
37+
38+ // H.264 decoder management
39+ if strings.Contains(codecPrefs.PreferredCodecs, "h264") {
40+ h264Ranks := []string{
41+ "vah264dec:512", // VA-API H.264 decoder
42+ "nvh264dec:384", // NVDEC H.264 decoder
43+ "avdec_h264:256", // Software H.264 decoder
44+ }
45+ rankSettings = append(rankSettings, h264Ranks...)
46+ }
47+
48+ // VP9 decoder demotion/blocking
49+ if codecPrefs.DisableVP9Hardware || codecPrefs.BlockVP9 {
50+ vp9Demotions := []string{
51+ "vaapivp9dec:0", // Block VA-API VP9
52+ "nvvp9dec:0", // Block NVDEC VP9
53+ }
54+ if codecPrefs.BlockVP9 {
55+ vp9Demotions = append(vp9Demotions, "vp9dec:0", "avdec_vp9:0") // Block all VP9
56+ } else {
57+ // Allow software VP9 with low priority
58+ vp9Demotions = append(vp9Demotions, "vp9dec:64", "avdec_vp9:64")
59+ }
60+ rankSettings = append(rankSettings, vp9Demotions...)
61+ log.Printf("[codec] Demoted/blocked VP9 decoders")
62+ }
63+
64+ // VP8 decoder blocking
65+ if codecPrefs.BlockVP8 {
66+ vp8Demotions := []string{
67+ "vaapivp8dec:0",
68+ "vp8dec:0",
69+ "avdec_vp8:0",
70+ }
71+ rankSettings = append(rankSettings, vp8Demotions...)
72+ log.Printf("[codec] Blocked VP8 decoders")
73+ }
74+
75+ // Apply the complete ranking system
76+ if len(rankSettings) > 0 {
77+ finalRank := strings.Join(rankSettings, ",")
78+ if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", finalRank); err != nil {
79+ log.Printf("[codec] Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err)
80+ } else {
81+ log.Printf("[codec] Set comprehensive GST_PLUGIN_FEATURE_RANK: %s", finalRank)
82+ }
83+ }
84+
85+ // Set video buffer sizes if specified
86+ if codecPrefs.VideoBufferSizeMB > 0 {
87+ bufferSize := strconv.Itoa(codecPrefs.VideoBufferSizeMB * 1024 * 1024)
88+
89+ if err := os.Setenv("GST_BUFFER_SIZE", bufferSize); err != nil {
90+ log.Printf("[codec] Warning: failed to set GST_BUFFER_SIZE: %v", err)
91+ }
92+
93+ if err := os.Setenv("GST_QUEUE2_MAX_SIZE_BYTES", bufferSize); err != nil {
94+ log.Printf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_BYTES: %v", err)
95+ }
96+
97+ log.Printf("[codec] Set video buffer size to %dMB", codecPrefs.VideoBufferSizeMB)
98+ }
99+
100+ // Set queue buffer time if specified
101+ if codecPrefs.QueueBufferTimeSec > 0 {
102+ // Convert seconds to nanoseconds for GStreamer
103+ bufferTime := strconv.Itoa(codecPrefs.QueueBufferTimeSec * 1000000000)
104+
105+ if err := os.Setenv("GST_QUEUE2_MAX_SIZE_TIME", bufferTime); err != nil {
106+ log.Printf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_TIME: %v", err)
107+ } else {
108+ log.Printf("[codec] Set queue buffer time to %ds", codecPrefs.QueueBufferTimeSec)
109+ }
110+ }
111+
112+ // Set additional codec-specific environment variables
113+ if codecPrefs.ForceAV1 {
114+ // Enable AV1 hardware decoding if available
115+ if err := os.Setenv("GST_AV1_DECODER_ENABLE_HW", "1"); err != nil {
116+ log.Printf("[codec] Warning: failed to set GST_AV1_DECODER_ENABLE_HW: %v", err)
117+ }
118+ }
119+
120+ // Disable video post-processing to avoid VA-API issues
121+ if err := os.Setenv("GST_VAAPI_DISABLE_VPP", "1"); err != nil {
122+ log.Printf("[codec] Warning: failed to set GST_VAAPI_DISABLE_VPP: %v", err)
123+ }
124+
125+ log.Printf("[codec] Codec configuration applied successfully")
126+}
127+
128+// BuildBlockedCodecsList converts config codec blocking preferences to a string slice
129+func BuildBlockedCodecsList(codecPrefs config.CodecConfig) []string {
130+ var blocked []string
131+
132+ if codecPrefs.BlockVP9 {
133+ blocked = append(blocked, "vp9")
134+ }
135+ if codecPrefs.BlockVP8 {
136+ blocked = append(blocked, "vp8")
137+ }
138+
139+ return blocked
140+}
@@ -0,0 +1,140 @@
1+package environment
2+
3+import (
4+ "log"
5+ "os"
6+ "strconv"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/config"
10+)
11+
12+// ApplyCodecConfiguration applies codec preferences to environment variables and GStreamer settings
13+func ApplyCodecConfiguration(codecPrefs config.CodecConfig) {
14+ log.Printf("[codec] Applying codec preferences: preferred=%s, force_av1=%t, block_vp9=%t",
15+ codecPrefs.PreferredCodecs, codecPrefs.ForceAV1, codecPrefs.BlockVP9)
16+
17+ // Build comprehensive GStreamer plugin rankings
18+ var rankSettings []string
19+
20+ // Get existing GST_PLUGIN_FEATURE_RANK or start fresh
21+ existingRank := os.Getenv("GST_PLUGIN_FEATURE_RANK")
22+ if existingRank != "" {
23+ rankSettings = strings.Split(existingRank, ",")
24+ }
25+
26+ // AV1 decoder promotions (high priority)
27+ if codecPrefs.ForceAV1 || strings.Contains(codecPrefs.PreferredCodecs, "av1") {
28+ av1Ranks := []string{
29+ "vaav1dec:768", // VA-API AV1 decoder (highest priority)
30+ "nvav1dec:512", // NVDEC AV1 decoder
31+ "av1dec:384", // Software AV1 decoder (fallback)
32+ "avdec_av1:256", // libav AV1 decoder
33+ }
34+ rankSettings = append(rankSettings, av1Ranks...)
35+ log.Printf("[codec] Promoted AV1 decoders for maximum priority")
36+ }
37+
38+ // H.264 decoder management
39+ if strings.Contains(codecPrefs.PreferredCodecs, "h264") {
40+ h264Ranks := []string{
41+ "vah264dec:512", // VA-API H.264 decoder
42+ "nvh264dec:384", // NVDEC H.264 decoder
43+ "avdec_h264:256", // Software H.264 decoder
44+ }
45+ rankSettings = append(rankSettings, h264Ranks...)
46+ }
47+
48+ // VP9 decoder demotion/blocking
49+ if codecPrefs.DisableVP9Hardware || codecPrefs.BlockVP9 {
50+ vp9Demotions := []string{
51+ "vaapivp9dec:0", // Block VA-API VP9
52+ "nvvp9dec:0", // Block NVDEC VP9
53+ }
54+ if codecPrefs.BlockVP9 {
55+ vp9Demotions = append(vp9Demotions, "vp9dec:0", "avdec_vp9:0") // Block all VP9
56+ } else {
57+ // Allow software VP9 with low priority
58+ vp9Demotions = append(vp9Demotions, "vp9dec:64", "avdec_vp9:64")
59+ }
60+ rankSettings = append(rankSettings, vp9Demotions...)
61+ log.Printf("[codec] Demoted/blocked VP9 decoders")
62+ }
63+
64+ // VP8 decoder blocking
65+ if codecPrefs.BlockVP8 {
66+ vp8Demotions := []string{
67+ "vaapivp8dec:0",
68+ "vp8dec:0",
69+ "avdec_vp8:0",
70+ }
71+ rankSettings = append(rankSettings, vp8Demotions...)
72+ log.Printf("[codec] Blocked VP8 decoders")
73+ }
74+
75+ // Apply the complete ranking system
76+ if len(rankSettings) > 0 {
77+ finalRank := strings.Join(rankSettings, ",")
78+ if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", finalRank); err != nil {
79+ log.Printf("[codec] Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err)
80+ } else {
81+ log.Printf("[codec] Set comprehensive GST_PLUGIN_FEATURE_RANK: %s", finalRank)
82+ }
83+ }
84+
85+ // Set video buffer sizes if specified
86+ if codecPrefs.VideoBufferSizeMB > 0 {
87+ bufferSize := strconv.Itoa(codecPrefs.VideoBufferSizeMB * 1024 * 1024)
88+
89+ if err := os.Setenv("GST_BUFFER_SIZE", bufferSize); err != nil {
90+ log.Printf("[codec] Warning: failed to set GST_BUFFER_SIZE: %v", err)
91+ }
92+
93+ if err := os.Setenv("GST_QUEUE2_MAX_SIZE_BYTES", bufferSize); err != nil {
94+ log.Printf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_BYTES: %v", err)
95+ }
96+
97+ log.Printf("[codec] Set video buffer size to %dMB", codecPrefs.VideoBufferSizeMB)
98+ }
99+
100+ // Set queue buffer time if specified
101+ if codecPrefs.QueueBufferTimeSec > 0 {
102+ // Convert seconds to nanoseconds for GStreamer
103+ bufferTime := strconv.Itoa(codecPrefs.QueueBufferTimeSec * 1000000000)
104+
105+ if err := os.Setenv("GST_QUEUE2_MAX_SIZE_TIME", bufferTime); err != nil {
106+ log.Printf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_TIME: %v", err)
107+ } else {
108+ log.Printf("[codec] Set queue buffer time to %ds", codecPrefs.QueueBufferTimeSec)
109+ }
110+ }
111+
112+ // Set additional codec-specific environment variables
113+ if codecPrefs.ForceAV1 {
114+ // Enable AV1 hardware decoding if available
115+ if err := os.Setenv("GST_AV1_DECODER_ENABLE_HW", "1"); err != nil {
116+ log.Printf("[codec] Warning: failed to set GST_AV1_DECODER_ENABLE_HW: %v", err)
117+ }
118+ }
119+
120+ // Disable video post-processing to avoid VA-API issues
121+ if err := os.Setenv("GST_VAAPI_DISABLE_VPP", "1"); err != nil {
122+ log.Printf("[codec] Warning: failed to set GST_VAAPI_DISABLE_VPP: %v", err)
123+ }
124+
125+ log.Printf("[codec] Codec configuration applied successfully")
126+}
127+
128+// BuildBlockedCodecsList converts config codec blocking preferences to a string slice
129+func BuildBlockedCodecsList(codecPrefs config.CodecConfig) []string {
130+ var blocked []string
131+
132+ if codecPrefs.BlockVP9 {
133+ blocked = append(blocked, "vp9")
134+ }
135+ if codecPrefs.BlockVP8 {
136+ blocked = append(blocked, "vp8")
137+ }
138+
139+ return blocked
140+}
A internal/app/environment/locale.go +89 -0
@@ -0,0 +1,89 @@
1+package environment
2+
3+import (
4+ "log"
5+ "os"
6+ "os/exec"
7+ "strings"
8+)
9+
10+// DetectAndSetKeyboardLocale attempts to determine the current keyboard layout
11+// and hints it to the webkit input layer for accelerator compatibility.
12+func DetectAndSetKeyboardLocale() {
13+ const colonSplitParts = 2 // Number of parts when splitting on colon
14+
15+ // 0) Explicit override
16+ locale := os.Getenv("DUMBER_KEYBOARD_LOCALE")
17+ if locale == "" {
18+ locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix
19+ }
20+ // 1) Environment
21+ if locale == "" {
22+ locale = os.Getenv("LC_ALL")
23+ }
24+ if locale == "" {
25+ locale = os.Getenv("LANG")
26+ }
27+ if locale == "" {
28+ locale = os.Getenv("LC_CTYPE")
29+ }
30+ // Trim variants
31+ if i := strings.IndexByte(locale, '.'); i > 0 {
32+ locale = locale[:i]
33+ }
34+ if i := strings.IndexByte(locale, '@'); i > 0 {
35+ locale = locale[:i]
36+ }
37+ if i := strings.IndexByte(locale, '_'); i > 0 {
38+ locale = locale[:i]
39+ }
40+
41+ // 2) Try XKB env override
42+ if locale == "" {
43+ locale = os.Getenv("XKB_DEFAULT_LAYOUT")
44+ }
45+
46+ // 3) Best-effort probe of setxkbmap/localectl (non-fatal)
47+ if locale == "" {
48+ if out, err := exec.Command("localectl", "status").Output(); err == nil {
49+ s := string(out)
50+ for _, line := range strings.Split(s, "\n") {
51+ line = strings.TrimSpace(line)
52+ if strings.HasPrefix(strings.ToLower(line), "x11 layout:") || strings.HasPrefix(strings.ToLower(line), "keyboard layout:") {
53+ parts := strings.SplitN(line, ":", colonSplitParts)
54+ if len(parts) == colonSplitParts {
55+ cand := strings.TrimSpace(parts[1])
56+ if cand != "" {
57+ locale = cand
58+ }
59+ }
60+ break
61+ }
62+ }
63+ }
64+ }
65+ if locale == "" {
66+ if out, err := exec.Command("setxkbmap", "-query").Output(); err == nil {
67+ s := string(out)
68+ for _, line := range strings.Split(s, "\n") {
69+ line = strings.TrimSpace(line)
70+ if strings.HasPrefix(strings.ToLower(line), "layout:") {
71+ parts := strings.SplitN(line, ":", colonSplitParts)
72+ if len(parts) == colonSplitParts {
73+ cand := strings.TrimSpace(parts[1])
74+ if cand != "" {
75+ locale = cand
76+ }
77+ }
78+ break
79+ }
80+ }
81+ }
82+ }
83+
84+ if locale == "" {
85+ locale = "en"
86+ }
87+ // No layout-specific remaps; log for diagnostics only
88+ log.Printf("[locale] keyboard locale detected: %s", locale)
89+}
@@ -0,0 +1,89 @@
1+package environment
2+
3+import (
4+ "log"
5+ "os"
6+ "os/exec"
7+ "strings"
8+)
9+
10+// DetectAndSetKeyboardLocale attempts to determine the current keyboard layout
11+// and hints it to the webkit input layer for accelerator compatibility.
12+func DetectAndSetKeyboardLocale() {
13+ const colonSplitParts = 2 // Number of parts when splitting on colon
14+
15+ // 0) Explicit override
16+ locale := os.Getenv("DUMBER_KEYBOARD_LOCALE")
17+ if locale == "" {
18+ locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix
19+ }
20+ // 1) Environment
21+ if locale == "" {
22+ locale = os.Getenv("LC_ALL")
23+ }
24+ if locale == "" {
25+ locale = os.Getenv("LANG")
26+ }
27+ if locale == "" {
28+ locale = os.Getenv("LC_CTYPE")
29+ }
30+ // Trim variants
31+ if i := strings.IndexByte(locale, '.'); i > 0 {
32+ locale = locale[:i]
33+ }
34+ if i := strings.IndexByte(locale, '@'); i > 0 {
35+ locale = locale[:i]
36+ }
37+ if i := strings.IndexByte(locale, '_'); i > 0 {
38+ locale = locale[:i]
39+ }
40+
41+ // 2) Try XKB env override
42+ if locale == "" {
43+ locale = os.Getenv("XKB_DEFAULT_LAYOUT")
44+ }
45+
46+ // 3) Best-effort probe of setxkbmap/localectl (non-fatal)
47+ if locale == "" {
48+ if out, err := exec.Command("localectl", "status").Output(); err == nil {
49+ s := string(out)
50+ for _, line := range strings.Split(s, "\n") {
51+ line = strings.TrimSpace(line)
52+ if strings.HasPrefix(strings.ToLower(line), "x11 layout:") || strings.HasPrefix(strings.ToLower(line), "keyboard layout:") {
53+ parts := strings.SplitN(line, ":", colonSplitParts)
54+ if len(parts) == colonSplitParts {
55+ cand := strings.TrimSpace(parts[1])
56+ if cand != "" {
57+ locale = cand
58+ }
59+ }
60+ break
61+ }
62+ }
63+ }
64+ }
65+ if locale == "" {
66+ if out, err := exec.Command("setxkbmap", "-query").Output(); err == nil {
67+ s := string(out)
68+ for _, line := range strings.Split(s, "\n") {
69+ line = strings.TrimSpace(line)
70+ if strings.HasPrefix(strings.ToLower(line), "layout:") {
71+ parts := strings.SplitN(line, ":", colonSplitParts)
72+ if len(parts) == colonSplitParts {
73+ cand := strings.TrimSpace(parts[1])
74+ if cand != "" {
75+ locale = cand
76+ }
77+ }
78+ break
79+ }
80+ }
81+ }
82+ }
83+
84+ if locale == "" {
85+ locale = "en"
86+ }
87+ // No layout-specific remaps; log for diagnostics only
88+ log.Printf("[locale] keyboard locale detected: %s", locale)
89+}
A internal/app/environment/media.go +32 -0
@@ -0,0 +1,32 @@
1+package environment
2+
3+import (
4+ "log"
5+ "os"
6+)
7+
8+// SetupMediaPipeline proactively hardens media pipeline to avoid WebProcess crashes from buggy VAAPI/plugins.
9+// Can be disabled with DUMBER_MEDIA_SAFE=0
10+func SetupMediaPipeline() {
11+ if os.Getenv("DUMBER_MEDIA_SAFE") != "0" {
12+ // Demote VAAPI-related elements so they won't be auto-picked
13+ if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" {
14+ if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0"); err != nil {
15+ log.Printf("Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err)
16+ }
17+ }
18+ // Provide a safe audio sink to avoid autoaudiosink failures
19+ if os.Getenv("GST_AUDIO_SINK") == "" {
20+ if err := os.Setenv("GST_AUDIO_SINK", "fakesink"); err != nil {
21+ log.Printf("Warning: failed to set GST_AUDIO_SINK: %v", err)
22+ }
23+ }
24+ // Optionally disable media stream features that can engage complex pipelines
25+ if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" {
26+ if err := os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1"); err != nil {
27+ log.Printf("Warning: failed to set WEBKIT_DISABLE_MEDIA_STREAM: %v", err)
28+ }
29+ }
30+ log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)")
31+ }
32+}
@@ -0,0 +1,32 @@
1+package environment
2+
3+import (
4+ "log"
5+ "os"
6+)
7+
8+// SetupMediaPipeline proactively hardens media pipeline to avoid WebProcess crashes from buggy VAAPI/plugins.
9+// Can be disabled with DUMBER_MEDIA_SAFE=0
10+func SetupMediaPipeline() {
11+ if os.Getenv("DUMBER_MEDIA_SAFE") != "0" {
12+ // Demote VAAPI-related elements so they won't be auto-picked
13+ if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" {
14+ if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0"); err != nil {
15+ log.Printf("Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err)
16+ }
17+ }
18+ // Provide a safe audio sink to avoid autoaudiosink failures
19+ if os.Getenv("GST_AUDIO_SINK") == "" {
20+ if err := os.Setenv("GST_AUDIO_SINK", "fakesink"); err != nil {
21+ log.Printf("Warning: failed to set GST_AUDIO_SINK: %v", err)
22+ }
23+ }
24+ // Optionally disable media stream features that can engage complex pipelines
25+ if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" {
26+ if err := os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1"); err != nil {
27+ log.Printf("Warning: failed to set WEBKIT_DISABLE_MEDIA_STREAM: %v", err)
28+ }
29+ }
30+ log.Printf("[media] Safe mode enabled (override with DUMBER_MEDIA_SAFE=0)")
31+ }
32+}
A internal/app/messaging/handler.go +182 -0
@@ -0,0 +1,182 @@
1+package messaging
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "log"
7+ neturl "net/url"
8+
9+ "github.com/bnema/dumber/internal/app/constants"
10+ "github.com/bnema/dumber/pkg/webkit"
11+ "github.com/bnema/dumber/services"
12+)
13+
14+// Handler processes script messages from the WebView
15+type Handler struct {
16+ parserService *services.ParserService
17+ browserService *services.BrowserService
18+ webView *webkit.WebView
19+}
20+
21+// Message represents a script message from the WebView
22+type Message struct {
23+ Type string `json:"type"`
24+ URL string `json:"url"`
25+ Q string `json:"q"`
26+ Limit int `json:"limit"`
27+ Value string `json:"value"`
28+ // Wails fetch bridge
29+ ID string `json:"id"`
30+ Payload json.RawMessage `json:"payload"`
31+}
32+
33+// NewHandler creates a new message handler
34+func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler {
35+ return &Handler{
36+ parserService: parserService,
37+ browserService: browserService,
38+ }
39+}
40+
41+// Handle processes incoming script messages
42+func (h *Handler) Handle(payload string) {
43+ var msg Message
44+ if err := json.Unmarshal([]byte(payload), &msg); err != nil {
45+ return
46+ }
47+
48+ switch msg.Type {
49+ case "navigate":
50+ h.handleNavigation(msg)
51+ case "query":
52+ h.handleQuery(msg)
53+ case "wails":
54+ h.handleWailsBridge(msg)
55+ case "theme":
56+ h.handleTheme(msg)
57+ }
58+}
59+
60+// SetWebView sets the WebView reference (needed for script injection)
61+func (h *Handler) SetWebView(webView *webkit.WebView) {
62+ h.webView = webView
63+}
64+
65+// handleNavigation processes navigation requests from the frontend
66+func (h *Handler) handleNavigation(msg Message) {
67+ ctx := context.Background()
68+ res, err := h.parserService.ParseInput(ctx, msg.URL)
69+ if err == nil {
70+ if _, navErr := h.browserService.Navigate(ctx, res.URL); navErr != nil {
71+ log.Printf("Warning: failed to navigate to %s: %v", res.URL, navErr)
72+ }
73+ if h.webView != nil {
74+ _ = h.webView.LoadURL(res.URL)
75+ if z, zerr := h.browserService.GetZoomLevel(ctx, res.URL); zerr == nil {
76+ _ = h.webView.SetZoom(z)
77+ }
78+ }
79+ }
80+}
81+
82+// handleQuery processes history search queries from the frontend
83+func (h *Handler) handleQuery(msg Message) {
84+ ctx := context.Background()
85+ limit := msg.Limit
86+ if limit <= 0 || limit > 25 {
87+ limit = 10
88+ }
89+ entries, err := h.browserService.SearchHistory(ctx, msg.Q, limit)
90+ if err != nil {
91+ return
92+ }
93+
94+ // Map to lightweight items for frontend
95+ type item struct {
96+ URL string `json:"url"`
97+ Favicon string `json:"favicon"`
98+ }
99+
100+ buildFavicon := func(raw string) string {
101+ u, err := h.parserService.ParseInput(ctx, raw)
102+ if err != nil || u.URL == "" {
103+ return ""
104+ }
105+ parsed, perr := neturl.Parse(u.URL)
106+ if perr != nil || parsed.Host == "" {
107+ return ""
108+ }
109+ scheme := parsed.Scheme
110+ if scheme == "" {
111+ scheme = "https"
112+ }
113+ return scheme + "://" + parsed.Host + "/favicon.ico"
114+ }
115+
116+ items := make([]item, 0, len(entries))
117+ for _, e := range entries {
118+ items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)})
119+ }
120+ b, _ := json.Marshal(items)
121+ // Update suggestions in page
122+ if h.webView != nil {
123+ _ = h.webView.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")")
124+ }
125+}
126+
127+// handleWailsBridge processes Wails runtime bridge calls for homepage
128+func (h *Handler) handleWailsBridge(msg Message) {
129+ // Payload contains { methodID, methodName?, args }
130+ var p struct {
131+ MethodID uint32 `json:"methodID"`
132+ MethodName string `json:"methodName"`
133+ Args json.RawMessage `json:"args"`
134+ }
135+ if err := json.Unmarshal(msg.Payload, &p); err != nil {
136+ return
137+ }
138+
139+ // Only implement the IDs we need
140+ switch p.MethodID {
141+ case constants.HashGetRecentHistory: // BrowserService.GetRecentHistory(limit)
142+ var args []interface{}
143+ _ = json.Unmarshal(p.Args, &args)
144+ limit := 50
145+ if len(args) > 0 {
146+ if f, ok := args[0].(float64); ok {
147+ limit = int(f)
148+ }
149+ }
150+ ctx := context.Background()
151+ entries, err := h.browserService.GetRecentHistory(ctx, limit)
152+ if err != nil {
153+ return
154+ }
155+ resp, _ := json.Marshal(entries)
156+ if h.webView != nil {
157+ _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
158+ }
159+ case constants.HashGetSearchShortcuts: // BrowserService.GetSearchShortcuts()
160+ ctx := context.Background()
161+ shortcuts, err := h.browserService.GetSearchShortcuts(ctx)
162+ if err != nil {
163+ return
164+ }
165+ resp, _ := json.Marshal(shortcuts)
166+ if h.webView != nil {
167+ _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
168+ }
169+ default:
170+ // Return empty JSON to avoid breaking UI
171+ if h.webView != nil {
172+ _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', '{}')")
173+ }
174+ }
175+}
176+
177+// handleTheme processes theme-related messages
178+func (h *Handler) handleTheme(msg Message) {
179+ if msg.Value != "" {
180+ log.Printf("[theme] page reported color-scheme: %s", msg.Value)
181+ }
182+}
@@ -0,0 +1,182 @@
1+package messaging
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "log"
7+ neturl "net/url"
8+
9+ "github.com/bnema/dumber/internal/app/constants"
10+ "github.com/bnema/dumber/pkg/webkit"
11+ "github.com/bnema/dumber/services"
12+)
13+
14+// Handler processes script messages from the WebView
15+type Handler struct {
16+ parserService *services.ParserService
17+ browserService *services.BrowserService
18+ webView *webkit.WebView
19+}
20+
21+// Message represents a script message from the WebView
22+type Message struct {
23+ Type string `json:"type"`
24+ URL string `json:"url"`
25+ Q string `json:"q"`
26+ Limit int `json:"limit"`
27+ Value string `json:"value"`
28+ // Wails fetch bridge
29+ ID string `json:"id"`
30+ Payload json.RawMessage `json:"payload"`
31+}
32+
33+// NewHandler creates a new message handler
34+func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler {
35+ return &Handler{
36+ parserService: parserService,
37+ browserService: browserService,
38+ }
39+}
40+
41+// Handle processes incoming script messages
42+func (h *Handler) Handle(payload string) {
43+ var msg Message
44+ if err := json.Unmarshal([]byte(payload), &msg); err != nil {
45+ return
46+ }
47+
48+ switch msg.Type {
49+ case "navigate":
50+ h.handleNavigation(msg)
51+ case "query":
52+ h.handleQuery(msg)
53+ case "wails":
54+ h.handleWailsBridge(msg)
55+ case "theme":
56+ h.handleTheme(msg)
57+ }
58+}
59+
60+// SetWebView sets the WebView reference (needed for script injection)
61+func (h *Handler) SetWebView(webView *webkit.WebView) {
62+ h.webView = webView
63+}
64+
65+// handleNavigation processes navigation requests from the frontend
66+func (h *Handler) handleNavigation(msg Message) {
67+ ctx := context.Background()
68+ res, err := h.parserService.ParseInput(ctx, msg.URL)
69+ if err == nil {
70+ if _, navErr := h.browserService.Navigate(ctx, res.URL); navErr != nil {
71+ log.Printf("Warning: failed to navigate to %s: %v", res.URL, navErr)
72+ }
73+ if h.webView != nil {
74+ _ = h.webView.LoadURL(res.URL)
75+ if z, zerr := h.browserService.GetZoomLevel(ctx, res.URL); zerr == nil {
76+ _ = h.webView.SetZoom(z)
77+ }
78+ }
79+ }
80+}
81+
82+// handleQuery processes history search queries from the frontend
83+func (h *Handler) handleQuery(msg Message) {
84+ ctx := context.Background()
85+ limit := msg.Limit
86+ if limit <= 0 || limit > 25 {
87+ limit = 10
88+ }
89+ entries, err := h.browserService.SearchHistory(ctx, msg.Q, limit)
90+ if err != nil {
91+ return
92+ }
93+
94+ // Map to lightweight items for frontend
95+ type item struct {
96+ URL string `json:"url"`
97+ Favicon string `json:"favicon"`
98+ }
99+
100+ buildFavicon := func(raw string) string {
101+ u, err := h.parserService.ParseInput(ctx, raw)
102+ if err != nil || u.URL == "" {
103+ return ""
104+ }
105+ parsed, perr := neturl.Parse(u.URL)
106+ if perr != nil || parsed.Host == "" {
107+ return ""
108+ }
109+ scheme := parsed.Scheme
110+ if scheme == "" {
111+ scheme = "https"
112+ }
113+ return scheme + "://" + parsed.Host + "/favicon.ico"
114+ }
115+
116+ items := make([]item, 0, len(entries))
117+ for _, e := range entries {
118+ items = append(items, item{URL: e.URL, Favicon: buildFavicon(e.URL)})
119+ }
120+ b, _ := json.Marshal(items)
121+ // Update suggestions in page
122+ if h.webView != nil {
123+ _ = h.webView.InjectScript("window.__dumber_setSuggestions && window.__dumber_setSuggestions(" + string(b) + ")")
124+ }
125+}
126+
127+// handleWailsBridge processes Wails runtime bridge calls for homepage
128+func (h *Handler) handleWailsBridge(msg Message) {
129+ // Payload contains { methodID, methodName?, args }
130+ var p struct {
131+ MethodID uint32 `json:"methodID"`
132+ MethodName string `json:"methodName"`
133+ Args json.RawMessage `json:"args"`
134+ }
135+ if err := json.Unmarshal(msg.Payload, &p); err != nil {
136+ return
137+ }
138+
139+ // Only implement the IDs we need
140+ switch p.MethodID {
141+ case constants.HashGetRecentHistory: // BrowserService.GetRecentHistory(limit)
142+ var args []interface{}
143+ _ = json.Unmarshal(p.Args, &args)
144+ limit := 50
145+ if len(args) > 0 {
146+ if f, ok := args[0].(float64); ok {
147+ limit = int(f)
148+ }
149+ }
150+ ctx := context.Background()
151+ entries, err := h.browserService.GetRecentHistory(ctx, limit)
152+ if err != nil {
153+ return
154+ }
155+ resp, _ := json.Marshal(entries)
156+ if h.webView != nil {
157+ _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
158+ }
159+ case constants.HashGetSearchShortcuts: // BrowserService.GetSearchShortcuts()
160+ ctx := context.Background()
161+ shortcuts, err := h.browserService.GetSearchShortcuts(ctx)
162+ if err != nil {
163+ return
164+ }
165+ resp, _ := json.Marshal(shortcuts)
166+ if h.webView != nil {
167+ _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
168+ }
169+ default:
170+ // Return empty JSON to avoid breaking UI
171+ if h.webView != nil {
172+ _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', '{}')")
173+ }
174+ }
175+}
176+
177+// handleTheme processes theme-related messages
178+func (h *Handler) handleTheme(msg Message) {
179+ if msg.Value != "" {
180+ log.Printf("[theme] page reported color-scheme: %s", msg.Value)
181+ }
182+}
A internal/logging/crash.go +106 -0
@@ -0,0 +1,106 @@
1+package logging
2+
3+import (
4+ "fmt"
5+ "os"
6+ "os/signal"
7+ "runtime"
8+ "runtime/debug"
9+ "syscall"
10+)
11+
12+// SetupCrashHandler sets up signal handlers to catch crashes and log them
13+func SetupCrashHandler() {
14+ c := make(chan os.Signal, 1)
15+
16+ // Listen for crash signals
17+ signal.Notify(c,
18+ syscall.SIGSEGV, // Segmentation violation
19+ syscall.SIGABRT, // Abort signal
20+ syscall.SIGFPE, // Floating point exception
21+ syscall.SIGBUS, // Bus error
22+ syscall.SIGILL, // Illegal instruction
23+ )
24+
25+ go func() {
26+ sig := <-c
27+ handleCrash(sig)
28+ }()
29+}
30+
31+// SetupPanicRecovery sets up a global panic recovery handler
32+// This should be called with defer at the start of main functions
33+func SetupPanicRecovery() {
34+ if r := recover(); r != nil {
35+ logPanic(r)
36+ }
37+}
38+
39+// handleCrash logs crash information and exits
40+func handleCrash(sig os.Signal) {
41+ logger := GetLogger()
42+ if logger == nil {
43+ fmt.Fprintf(os.Stderr, "CRASH: Caught signal %v but no logger available\n", sig)
44+ os.Exit(1)
45+ }
46+
47+ // Log the crash
48+ crashMsg := fmt.Sprintf("CRASH: Caught fatal signal %v", sig)
49+ logger.writeLog(FATAL, crashMsg, "CRASH")
50+
51+ // Log stack trace
52+ stack := debug.Stack()
53+ stackMsg := fmt.Sprintf("Stack trace:\n%s", string(stack))
54+ logger.writeLog(FATAL, stackMsg, "CRASH")
55+
56+ // Log system info
57+ sysInfo := fmt.Sprintf("Go version: %s, OS: %s, Arch: %s, NumCPU: %d",
58+ runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
59+ logger.writeLog(FATAL, sysInfo, "CRASH")
60+
61+ // Log memory stats
62+ var m runtime.MemStats
63+ runtime.ReadMemStats(&m)
64+ memInfo := fmt.Sprintf("Memory - Alloc: %d KB, TotalAlloc: %d KB, Sys: %d KB, NumGC: %d",
65+ m.Alloc/1024, m.TotalAlloc/1024, m.Sys/1024, m.NumGC)
66+ logger.writeLog(FATAL, memInfo, "CRASH")
67+
68+ // Force flush logs
69+ if logger.rotator != nil {
70+ logger.rotator.Close()
71+ }
72+
73+ // Exit with error code
74+ os.Exit(128 + int(sig.(syscall.Signal)))
75+}
76+
77+// logPanic logs panic information
78+func logPanic(r any) {
79+ logger := GetLogger()
80+ if logger == nil {
81+ fmt.Fprintf(os.Stderr, "PANIC: %v but no logger available\n", r)
82+ return
83+ }
84+
85+ // Log the panic
86+ panicMsg := fmt.Sprintf("PANIC: %v", r)
87+ logger.writeLog(FATAL, panicMsg, "PANIC")
88+
89+ // Log stack trace
90+ stack := debug.Stack()
91+ stackMsg := fmt.Sprintf("Stack trace:\n%s", string(stack))
92+ logger.writeLog(FATAL, stackMsg, "PANIC")
93+
94+ // Re-panic to maintain normal panic behavior
95+ panic(r)
96+}
97+
98+// LogCrashInfo logs information that might be helpful for crash analysis
99+func LogCrashInfo(context string) {
100+ logger := GetLogger()
101+ if logger == nil {
102+ return
103+ }
104+
105+ logger.writeLog(DEBUG, fmt.Sprintf("Crash context checkpoint: %s", context), "CRASH_DEBUG")
106+}
@@ -0,0 +1,106 @@
1+package logging
2+
3+import (
4+ "fmt"
5+ "os"
6+ "os/signal"
7+ "runtime"
8+ "runtime/debug"
9+ "syscall"
10+)
11+
12+// SetupCrashHandler sets up signal handlers to catch crashes and log them
13+func SetupCrashHandler() {
14+ c := make(chan os.Signal, 1)
15+
16+ // Listen for crash signals
17+ signal.Notify(c,
18+ syscall.SIGSEGV, // Segmentation violation
19+ syscall.SIGABRT, // Abort signal
20+ syscall.SIGFPE, // Floating point exception
21+ syscall.SIGBUS, // Bus error
22+ syscall.SIGILL, // Illegal instruction
23+ )
24+
25+ go func() {
26+ sig := <-c
27+ handleCrash(sig)
28+ }()
29+}
30+
31+// SetupPanicRecovery sets up a global panic recovery handler
32+// This should be called with defer at the start of main functions
33+func SetupPanicRecovery() {
34+ if r := recover(); r != nil {
35+ logPanic(r)
36+ }
37+}
38+
39+// handleCrash logs crash information and exits
40+func handleCrash(sig os.Signal) {
41+ logger := GetLogger()
42+ if logger == nil {
43+ fmt.Fprintf(os.Stderr, "CRASH: Caught signal %v but no logger available\n", sig)
44+ os.Exit(1)
45+ }
46+
47+ // Log the crash
48+ crashMsg := fmt.Sprintf("CRASH: Caught fatal signal %v", sig)
49+ logger.writeLog(FATAL, crashMsg, "CRASH")
50+
51+ // Log stack trace
52+ stack := debug.Stack()
53+ stackMsg := fmt.Sprintf("Stack trace:\n%s", string(stack))
54+ logger.writeLog(FATAL, stackMsg, "CRASH")
55+
56+ // Log system info
57+ sysInfo := fmt.Sprintf("Go version: %s, OS: %s, Arch: %s, NumCPU: %d",
58+ runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.NumCPU())
59+ logger.writeLog(FATAL, sysInfo, "CRASH")
60+
61+ // Log memory stats
62+ var m runtime.MemStats
63+ runtime.ReadMemStats(&m)
64+ memInfo := fmt.Sprintf("Memory - Alloc: %d KB, TotalAlloc: %d KB, Sys: %d KB, NumGC: %d",
65+ m.Alloc/1024, m.TotalAlloc/1024, m.Sys/1024, m.NumGC)
66+ logger.writeLog(FATAL, memInfo, "CRASH")
67+
68+ // Force flush logs
69+ if logger.rotator != nil {
70+ logger.rotator.Close()
71+ }
72+
73+ // Exit with error code
74+ os.Exit(128 + int(sig.(syscall.Signal)))
75+}
76+
77+// logPanic logs panic information
78+func logPanic(r any) {
79+ logger := GetLogger()
80+ if logger == nil {
81+ fmt.Fprintf(os.Stderr, "PANIC: %v but no logger available\n", r)
82+ return
83+ }
84+
85+ // Log the panic
86+ panicMsg := fmt.Sprintf("PANIC: %v", r)
87+ logger.writeLog(FATAL, panicMsg, "PANIC")
88+
89+ // Log stack trace
90+ stack := debug.Stack()
91+ stackMsg := fmt.Sprintf("Stack trace:\n%s", string(stack))
92+ logger.writeLog(FATAL, stackMsg, "PANIC")
93+
94+ // Re-panic to maintain normal panic behavior
95+ panic(r)
96+}
97+
98+// LogCrashInfo logs information that might be helpful for crash analysis
99+func LogCrashInfo(context string) {
100+ logger := GetLogger()
101+ if logger == nil {
102+ return
103+ }
104+
105+ logger.writeLog(DEBUG, fmt.Sprintf("Crash context checkpoint: %s", context), "CRASH_DEBUG")
106+}

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