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