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