1package browser
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "time"
10
11 "github.com/bnema/dumber/internal/app/constants"
12 "github.com/bnema/dumber/internal/app/control"
13 "github.com/bnema/dumber/internal/app/messaging"
14 "github.com/bnema/dumber/internal/config"
15 "github.com/bnema/dumber/internal/filtering"
16 "github.com/bnema/dumber/internal/logging"
17 "github.com/bnema/dumber/internal/services"
18 "github.com/bnema/dumber/pkg/webkit"
19)
20
21// Note: Memory management and color palettes are now handled at the application level
22// The gotk4 webkit.Config only contains basic WebKit settings
23
24func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
25 // Ensure data directories exist for WebKit
26 dataDir, err := config.GetDataDir()
27 if err != nil {
28 return nil, err
29 }
30 stateDir, err := config.GetStateDir()
31 if err != nil {
32 return nil, err
33 }
34
35 webkitData := filepath.Join(dataDir, "webkit")
36 webkitCache := filepath.Join(stateDir, "webkit-cache")
37 if err := os.MkdirAll(webkitData, constants.DirPerm); err != nil {
38 return nil, err
39 }
40 if err := os.MkdirAll(webkitCache, constants.DirPerm); err != nil {
41 return nil, err
42 }
43
44 // Build WebKit configuration with data directories for persistence
45 cfg := &webkit.Config{
46 UserAgent: buildUserAgent(app.config),
47 EnableJavaScript: true,
48 EnableWebGL: true,
49 EnableMediaStream: true,
50 HardwareAcceleration: true,
51 DefaultFontSize: app.config.Appearance.DefaultFontSize,
52 MinimumFontSize: 8,
53 EnablePageCache: true, // Instant back/forward navigation (bfcache)
54 EnableSmoothScrolling: true, // Smooth scrolling animations
55 DataDir: webkitData,
56 CacheDir: webkitCache,
57 AppearanceConfigJSON: app.buildAppearanceConfigJSON(),
58 CreateWindow: true, // Default to creating a window for standalone WebViews
59 }
60
61 return cfg, nil
62}
63
64// buildUserAgent constructs the user agent string from config
65func buildUserAgent(cfg *config.Config) string {
66 if cfg.CodecPreferences.CustomUserAgent != "" {
67 return cfg.CodecPreferences.CustomUserAgent
68 }
69 // Use default WebKit user agent
70 return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
71}
72
73// buildAppearanceConfigJSON builds the palette configuration as JSON
74// The GUI expects window.__dumber_palette with just the palette data
75func (app *BrowserApp) buildAppearanceConfigJSON() string {
76 if app.config == nil {
77 return ""
78 }
79
80 // Build palette config in the format main-world.ts expects
81 // It looks for window.__dumber_palette = { "light": {...}, "dark": {...} }
82 paletteConfig := map[string]interface{}{
83 "light": map[string]string{
84 "background": app.config.Appearance.LightPalette.Background,
85 "surface": app.config.Appearance.LightPalette.Surface,
86 "surface_variant": app.config.Appearance.LightPalette.SurfaceVariant,
87 "text": app.config.Appearance.LightPalette.Text,
88 "muted": app.config.Appearance.LightPalette.Muted,
89 "accent": app.config.Appearance.LightPalette.Accent,
90 "border": app.config.Appearance.LightPalette.Border,
91 },
92 "dark": map[string]string{
93 "background": app.config.Appearance.DarkPalette.Background,
94 "surface": app.config.Appearance.DarkPalette.Surface,
95 "surface_variant": app.config.Appearance.DarkPalette.SurfaceVariant,
96 "text": app.config.Appearance.DarkPalette.Text,
97 "muted": app.config.Appearance.DarkPalette.Muted,
98 "accent": app.config.Appearance.DarkPalette.Accent,
99 "border": app.config.Appearance.DarkPalette.Border,
100 },
101 }
102
103 payload, err := json.Marshal(paletteConfig)
104 if err != nil {
105 logging.Error(fmt.Sprintf("[webview] Failed to marshal palette config: %v", err))
106 return ""
107 }
108
109 return string(payload)
110}
111
112func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
113 pane := &BrowserPane{
114 webView: view,
115 }
116
117 pane.clipboardController = control.NewClipboardController(view)
118 pane.zoomController = control.NewZoomController(app.browserService, view)
119 pane.navigationController = control.NewNavigationController(
120 app.parserService,
121 app.browserService,
122 view,
123 pane.zoomController,
124 )
125 pane.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
126 pane.messageHandler.SetWebView(view)
127 pane.messageHandler.SetNavigationController(pane.navigationController)
128 pane.shortcutHandler = NewShortcutHandler(view, pane.clipboardController, app.config, app)
129
130 pane.zoomController.RegisterHandlers()
131 pane.shortcutHandler.RegisterShortcuts()
132
133 // Register navigation handler for workspace focus events if workspace exists
134 if app.workspace != nil {
135 app.workspace.RegisterNavigationHandler(view)
136 }
137
138 return pane, nil
139}
140
141func (app *BrowserApp) createPaneForView(view *webkit.WebView) (*BrowserPane, error) {
142 pane, err := app.buildPane(view)
143 if err != nil {
144 return nil, err
145 }
146
147 // Generate unique pane ID
148 pane.SetID(fmt.Sprintf("pane-%d-%p", time.Now().Unix(), view))
149 pane.initializeGUITracking()
150
151 // GUI manager will be loaded on-demand when needed
152 logging.Debug(fmt.Sprintf("[webview] Created pane for webview: %d", view.ID()))
153
154 app.attachPaneHandlers(pane)
155 return pane, nil
156}
157
158func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
159 if pane == nil || pane.webView == nil {
160 return
161 }
162
163 pane.webView.RegisterTitleChangedHandler(func(title string) {
164 if title == "" {
165 return
166 }
167 url := pane.webView.GetCurrentURL()
168 if url == "" {
169 return
170 }
171
172 // Update database with new title
173 go func(url, title string) {
174 ctx := context.Background()
175 if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil {
176 logging.Warn(fmt.Sprintf("Warning: failed to update page title: %v", err))
177 }
178 }(url, title)
179
180 // Update stacked pane title bar if this pane is in a stack
181 if app.workspace != nil {
182 app.workspace.UpdateTitleBar(pane.webView, title)
183 }
184 })
185
186 pane.webView.RegisterScriptMessageHandler(func(payload string) {
187 if app.workspace != nil && shouldFocusForScriptMessage(payload) {
188 app.workspace.focusByView(pane.webView)
189 }
190 pane.messageHandler.Handle(payload)
191 })
192
193 // Page load progress - drive tab-level progress bar
194 pane.webView.RegisterLoadStartedHandler(func() {
195 app.handleLoadProgress(pane.webView, 0.0, true)
196 })
197 pane.webView.RegisterLoadFinishedHandler(func() {
198 app.handleLoadProgress(pane.webView, 1.0, false)
199 })
200 pane.webView.RegisterLoadProgressHandler(func(progress float64) {
201 app.handleLoadProgress(pane.webView, progress, true)
202 })
203
204 if pane.messageHandler != nil && pane.navigationController != nil {
205 pane.messageHandler.SetNavigationController(pane.navigationController)
206 }
207
208 // Setup WebKit-native popup lifecycle using create/ready-to-show/close signals
209 if app.workspace != nil {
210 node := app.workspace.GetNodeForWebView(pane.webView)
211 if node != nil {
212 app.workspace.setupPopupHandling(pane.webView, node)
213 logging.Debug(fmt.Sprintf("[webview] Setup native popup handling for WebView ID: %d", pane.webView.ID()))
214 }
215 }
216}
217
218// handleLoadProgress routes WebView load progress events to the tab manager for UI display.
219func (app *BrowserApp) handleLoadProgress(view *webkit.WebView, progress float64, loading bool) {
220 if app == nil || view == nil || app.tabManager == nil {
221 return
222 }
223
224 // Run asynchronously to avoid blocking the GTK/main thread during load-changed signals.
225 go app.tabManager.updateProgressForWebView(view, progress, loading)
226}
227
228// shouldFocusForScriptMessage filters script messages and only allows focus handoff
229// for explicit user-driven actions (navigation, history commands, popup lifecycle).
230// Background bootstrap messages (palette sync, workspace bridge chatter, etc.) are
231// ignored so they don't steal focus from the user's active pane.
232func shouldFocusForScriptMessage(payload string) bool {
233 var msg messaging.Message
234 if err := json.Unmarshal([]byte(payload), &msg); err != nil {
235 return true // keep legacy behaviour if parsing fails
236 }
237
238 switch msg.Type {
239 case "navigate", "query", "wails", "history_recent", "history_stats", "history_search", "history_delete", "close-popup":
240 return true
241 default:
242 return false
243 }
244}
245
246// createWebView creates and configures the WebView
247func (app *BrowserApp) createWebView() error {
248 logging.Info(fmt.Sprintf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable()))
249
250 cfg, err := app.buildWebkitConfig()
251 if err != nil {
252 return err
253 }
254
255 // Note: Window creation is now handled automatically by webkit.NewWebView
256 view, err := webkit.NewWebView(cfg)
257
258 if err != nil {
259 return err
260 }
261
262 pane, err := app.createPaneForView(view)
263 if err != nil {
264 return err
265 }
266
267 app.webView = view
268 app.zoomController = pane.zoomController
269 app.navigationController = pane.navigationController
270 app.clipboardController = pane.clipboardController
271 app.messageHandler = pane.messageHandler
272 app.shortcutHandler = pane.shortcutHandler
273
274 // Initialize tab manager (which manages workspaces)
275 window := view.Window()
276 if window != nil && app.tabManager == nil {
277 app.tabManager = NewTabManager(app, window)
278
279 // Determine initial URL
280 initialURL := "about:blank"
281
282 // Initialize tab system (creates root container with tab bar and first tab)
283 if err := app.tabManager.Initialize(initialURL); err != nil {
284 logging.Error(fmt.Sprintf("ERROR: failed to initialize tab manager: %v", err))
285 return err
286 }
287
288 // Set tab manager's root container as window content
289 rootContainer := app.tabManager.GetRootContainer()
290 if rootContainer != nil {
291 window.SetChild(rootContainer)
292 logging.Info(fmt.Sprintf("[tabs] Tab system initialized and set as window content"))
293 } else {
294 logging.Error(fmt.Sprintf("ERROR: tab manager root container is nil"))
295 return fmt.Errorf("failed to get tab manager root container")
296 }
297 }
298
299 // Initialize window-level global shortcuts AFTER tab manager is set up
300 if window != nil {
301 app.windowShortcutHandler = NewWindowShortcutHandler(window, app)
302 if app.windowShortcutHandler != nil {
303 logging.Info(fmt.Sprintf("Window-level global shortcuts initialized"))
304 } else {
305 logging.Warn(fmt.Sprintf("Warning: failed to initialize window-level shortcuts"))
306 }
307 }
308
309 app.setupWebViewIntegration()
310
311 // Initialize FaviconService after WebView is created
312 if err := app.initializeFaviconService(); err != nil {
313 logging.Warn(fmt.Sprintf("Warning: failed to initialize favicon service: %v", err))
314 // Continue without favicon service - not critical
315 }
316
317 // Apply initial zoom and show window
318 app.zoomController.ApplyInitialZoom()
319
320 logging.Info(fmt.Sprintf("Showing WebView window…"))
321 if err := view.Show(); err != nil {
322 logging.Warn(fmt.Sprintf("Warning: failed to show WebView: %v", err))
323 } else if !webkit.IsNativeAvailable() {
324 logging.Info(fmt.Sprintf("Notice: running without webkit_cgo tag — no native window will be displayed."))
325 }
326
327 return nil
328}
329
330// setupWebViewIntegration connects the WebView to browser services
331func (app *BrowserApp) setupWebViewIntegration() {
332 app.browserService.AttachWebView(app.webView)
333
334 // GUI bundle is loaded via WebKit user scripts in SetupUserContentManager
335 // Appearance config is also injected at document-start via UserContentManager
336
337 // Use native window as title updater
338 if win := app.webView.Window(); win != nil {
339 app.browserService.SetWindowTitleUpdater(win)
340 }
341}
342
343// initializeFaviconService creates and initializes the FaviconService
344func (app *BrowserApp) initializeFaviconService() error {
345 // Get the FaviconDatabase from the WebView
346 faviconDB := app.webView.GetFaviconDatabase()
347 if faviconDB == nil {
348 return fmt.Errorf("favicon database not available")
349 }
350
351 // Get the WebKit data directory
352 dataDir, err := config.GetDataDir()
353 if err != nil {
354 return fmt.Errorf("failed to get data directory: %w", err)
355 }
356 webkitData := filepath.Join(dataDir, "webkit")
357
358 // Create FaviconService
359 faviconService, err := services.NewFaviconService(faviconDB, app.queries, webkitData)
360 if err != nil {
361 return fmt.Errorf("failed to create favicon service: %w", err)
362 }
363
364 app.faviconService = faviconService
365 logging.Info(fmt.Sprintf("[favicon] FaviconService initialized successfully"))
366 return nil
367}
368
369// setupContentBlocking initializes the content blocking system with proper timing
370func (app *BrowserApp) setupContentBlocking() error {
371 logging.Info(fmt.Sprintf("Initializing content blocking system..."))
372
373 // Enable WebKit debug logging if requested
374 webkit.SetupWebKitDebugLogging()
375
376 defer func() {
377 if r := recover(); r != nil {
378 logging.Error(fmt.Sprintf("Content blocking setup panic recovered: %v", r))
379 }
380 }()
381
382 // Setup filter system
383 filterManager, err := filtering.SetupFilterSystem()
384 if err != nil {
385 logging.Warn(fmt.Sprintf("Warning: Failed to setup filter system: %v", err))
386 return nil // Don't fail browser startup, continue without filters
387 }
388
389 // Store reference to filter manager
390 app.filterManager = filterManager
391
392 // Get data directory for content blocking service
393 dataDir, err := config.GetDataDir()
394 if err != nil {
395 logging.Warn(fmt.Sprintf("Warning: Failed to get data dir for content blocking: %v", err))
396 return nil
397 }
398
399 // Create content blocking service
400 cbService, err := filtering.NewContentBlockingService(dataDir+"/webkit", filterManager)
401 if err != nil {
402 logging.Warn(fmt.Sprintf("Warning: Failed to create content blocking service: %v", err))
403 return nil
404 }
405 app.contentBlockingService = cbService
406
407 // Set up callback to apply network filters when they become ready
408 // This callback will be called from the async filter loading process
409 filterManager.SetFiltersReadyCallback(func() {
410 logging.Info(fmt.Sprintf("[filtering] Filters ready, distributing to all WebViews..."))
411
412 // Get the WebKit JSON rules from filter manager
413 filterJSON, err := filterManager.GetNetworkFilters()
414 if err != nil {
415 logging.Error(fmt.Sprintf("[filtering] Failed to get network filters: %v", err))
416 return
417 }
418
419 if len(filterJSON) == 0 {
420 logging.Info(fmt.Sprintf("[filtering] No network filters to apply"))
421 return
422 }
423
424 logging.Debug(fmt.Sprintf("[filtering] Got %d bytes of WebKit JSON rules", len(filterJSON)))
425
426 // Signal the service that filters are ready - it will apply to all registered WebViews
427 cbService.SetFiltersReady(filterJSON)
428 logging.Info(fmt.Sprintf("[filtering] Content blocking enabled for all WebViews"))
429 })
430
431 // Start async filter loading
432 // This allows filters to compile in the background while the browser starts
433 go func() {
434 if err := filtering.InitializeFiltersAsync(filterManager); err != nil {
435 logging.Warn(fmt.Sprintf("Warning: failed to initialize filters asynchronously: %v", err))
436 }
437 }()
438
439 logging.Info(fmt.Sprintf("Content blocking system initialization started"))
440 return nil
441}
442
443// RegisterWebViewForFiltering registers a WebView with the content blocking service.
444// This should be called for every new WebView created in the application.
445func (app *BrowserApp) RegisterWebViewForFiltering(wv *webkit.WebView) {
446 if app.contentBlockingService != nil && wv != nil {
447 app.contentBlockingService.RegisterWebView(wv)
448 }
449}
450
451// UnregisterWebViewFromFiltering removes a WebView from the content blocking service.
452func (app *BrowserApp) UnregisterWebViewFromFiltering(wv *webkit.WebView) {
453 if app.contentBlockingService != nil && wv != nil {
454 app.contentBlockingService.UnregisterWebView(wv)
455 }
456}