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