1package browser
2
3import (
4 "context"
5 "fmt"
6 "log"
7 "os"
8 "path/filepath"
9 "strings"
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/environment"
15 "github.com/bnema/dumber/internal/app/messaging"
16 "github.com/bnema/dumber/internal/config"
17 "github.com/bnema/dumber/internal/filtering"
18 "github.com/bnema/dumber/pkg/webkit"
19)
20
21// buildMemoryConfig converts config.WebkitMemoryConfig to webkit.MemoryConfig
22func buildMemoryConfig(cfg config.WebkitMemoryConfig) webkit.MemoryConfig {
23 mc := webkit.MemoryConfig{
24 MemoryLimitMB: cfg.MemoryLimitMB,
25 ConservativeThreshold: cfg.ConservativeThreshold,
26 StrictThreshold: cfg.StrictThreshold,
27 KillThreshold: cfg.KillThreshold,
28 PollIntervalSeconds: cfg.PollIntervalSeconds,
29 EnableGCInterval: cfg.EnableGCInterval,
30 ProcessRecycleThreshold: cfg.ProcessRecycleThreshold,
31 EnablePageCache: cfg.EnablePageCache,
32 EnableMemoryMonitoring: cfg.EnableMemoryMonitoring,
33 }
34
35 // Convert string cache model to webkit constant
36 switch strings.ToLower(cfg.CacheModel) {
37 case "document_viewer", "documentviewer", "doc":
38 mc.CacheModel = webkit.CacheModelDocumentViewer
39 case "primary_web_browser", "primary", "primarywebbrowser":
40 mc.CacheModel = webkit.CacheModelPrimaryWebBrowser
41 default:
42 mc.CacheModel = webkit.CacheModelWebBrowser
43 }
44
45 return mc
46}
47
48func buildColorPalettes(cfg config.AppearanceConfig) webkit.ColorPalettes {
49 toPalette := func(src config.ColorPalette) webkit.ColorPalette {
50 return webkit.ColorPalette{
51 Background: src.Background,
52 Surface: src.Surface,
53 SurfaceVariant: src.SurfaceVariant,
54 Text: src.Text,
55 Muted: src.Muted,
56 Accent: src.Accent,
57 Border: src.Border,
58 }
59 }
60
61 return webkit.ColorPalettes{
62 Light: toPalette(cfg.LightPalette),
63 Dark: toPalette(cfg.DarkPalette),
64 }
65}
66
67func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
68 dataDir, err := config.GetDataDir()
69 if err != nil {
70 return nil, err
71 }
72 stateDir, err := config.GetStateDir()
73 if err != nil {
74 return nil, err
75 }
76
77 webkitData := filepath.Join(dataDir, "webkit")
78 webkitCache := filepath.Join(stateDir, "webkit-cache")
79 if err := os.MkdirAll(webkitData, constants.DirPerm); err != nil {
80 return nil, err
81 }
82 if err := os.MkdirAll(webkitCache, constants.DirPerm); err != nil {
83 return nil, err
84 }
85
86 cfg := &webkit.Config{
87 Assets: app.assets,
88 InitialURL: "dumb://homepage",
89 ZoomDefault: app.config.DefaultZoom,
90 EnableDeveloperExtras: true,
91 DataDir: webkitData,
92 CacheDir: webkitCache,
93 APIToken: app.config.APISecurity.Token,
94 DefaultSansFont: app.config.Appearance.SansFont,
95 DefaultSerifFont: app.config.Appearance.SerifFont,
96 DefaultMonospaceFont: app.config.Appearance.MonospaceFont,
97 DefaultFontSize: app.config.Appearance.DefaultFontSize,
98 Rendering: webkit.RenderingConfig{Mode: string(app.config.RenderingMode)},
99 UseDomZoom: app.config.UseDomZoom,
100 VideoAcceleration: webkit.VideoAccelerationConfig{
101 EnableVAAPI: app.config.VideoAcceleration.EnableVAAPI,
102 AutoDetectGPU: app.config.VideoAcceleration.AutoDetectGPU,
103 VAAPIDriverName: app.config.VideoAcceleration.VAAPIDriverName,
104 EnableAllDrivers: app.config.VideoAcceleration.EnableAllDrivers,
105 LegacyVAAPI: app.config.VideoAcceleration.LegacyVAAPI,
106 },
107 Memory: buildMemoryConfig(app.config.WebkitMemory),
108 CodecPreferences: webkit.CodecPreferencesConfig{
109 PreferredCodecs: strings.Split(app.config.CodecPreferences.PreferredCodecs, ","),
110 BlockedCodecs: environment.BuildBlockedCodecsList(app.config.CodecPreferences),
111 ForceAV1: app.config.CodecPreferences.ForceAV1,
112 CustomUserAgent: app.config.CodecPreferences.CustomUserAgent,
113 DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl,
114 },
115 Colors: buildColorPalettes(app.config.Appearance),
116 }
117
118 return cfg, nil
119}
120
121func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
122 pane := &BrowserPane{
123 webView: view,
124 }
125
126 pane.clipboardController = control.NewClipboardController(view)
127 pane.zoomController = control.NewZoomController(app.browserService, view)
128 pane.navigationController = control.NewNavigationController(
129 app.parserService,
130 app.browserService,
131 view,
132 pane.zoomController,
133 )
134 pane.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
135 pane.messageHandler.SetWebView(view)
136 pane.messageHandler.SetNavigationController(pane.navigationController)
137 pane.shortcutHandler = NewShortcutHandler(view, pane.clipboardController, app.config, app)
138
139 pane.zoomController.RegisterHandlers()
140 pane.shortcutHandler.RegisterShortcuts()
141
142 // Register navigation handler for workspace focus events if workspace exists
143 if app.workspace != nil {
144 app.workspace.RegisterNavigationHandler(view)
145 }
146
147 // Register favicon handlers for this webview (needed for each pane)
148 // This ensures favicons are detected and cached for all panes, not just the main webview
149 view.RegisterFaviconURIChangedHandler(func(pageURI, faviconURI string) {
150 // Call the private handler through a public wrapper or directly via browserService
151 // We can't call handleFaviconURIChanged directly as it's private, so we need to expose it
152 // For now, duplicate the minimal logic here
153 app.browserService.ProcessFaviconURI(pageURI, faviconURI)
154 })
155
156 return pane, nil
157}
158
159func (app *BrowserApp) createPaneForView(view *webkit.WebView) (*BrowserPane, error) {
160 pane, err := app.buildPane(view)
161 if err != nil {
162 return nil, err
163 }
164
165 // Generate unique pane ID
166 pane.SetID(fmt.Sprintf("pane-%d-%p", time.Now().Unix(), view))
167 pane.initializeGUITracking()
168
169 // GUI manager will be loaded on-demand when needed
170 log.Printf("[webview] Created pane for webview: %s", view.ID())
171
172 app.attachPaneHandlers(pane)
173 return pane, nil
174}
175
176func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
177 if pane == nil || pane.webView == nil {
178 return
179 }
180
181 pane.webView.RegisterTitleChangedHandler(func(title string) {
182 if title == "" {
183 return
184 }
185 url := pane.webView.GetCurrentURL()
186 if url == "" {
187 return
188 }
189
190 // Update database with new title
191 go func(url, title string) {
192 ctx := context.Background()
193 if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil {
194 log.Printf("Warning: failed to update page title: %v", err)
195 }
196 }(url, title)
197
198 // Update stacked pane title bar if this pane is in a stack
199 if app.workspace != nil {
200 app.workspace.UpdateTitleBar(pane.webView, title)
201 }
202 })
203
204 pane.webView.RegisterScriptMessageHandler(func(payload string) {
205 if app.workspace != nil {
206 app.workspace.focusByView(pane.webView)
207 }
208 pane.messageHandler.Handle(payload)
209 })
210
211 if pane.messageHandler != nil && pane.navigationController != nil {
212 pane.messageHandler.SetNavigationController(pane.navigationController)
213 }
214
215 pane.webView.RegisterPopupHandler(func(uri string) *webkit.WebView {
216 if app.workspace != nil {
217 return app.workspace.HandlePopup(pane.webView, uri)
218 }
219
220 // No workspace manager - just navigate in current pane
221 if uri != "" && pane.navigationController != nil {
222 go func() {
223 if err := pane.navigationController.NavigateToURL(uri); err != nil {
224 log.Printf("[webview] failed to navigate to URL %s: %v", uri, err)
225 }
226 }()
227 }
228 return nil
229 })
230}
231
232// createWebView creates and configures the WebView
233func (app *BrowserApp) createWebView() error {
234 log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
235
236 cfg, err := app.buildWebkitConfig()
237 if err != nil {
238 return err
239 }
240
241 // Main window needs a top-level window
242 cfg.CreateWindow = true
243
244 view, err := webkit.NewWebView(cfg)
245
246 if err != nil {
247 return err
248 }
249
250 pane, err := app.createPaneForView(view)
251 if err != nil {
252 return err
253 }
254
255 app.webView = view
256 app.zoomController = pane.zoomController
257 app.navigationController = pane.navigationController
258 app.clipboardController = pane.clipboardController
259 app.messageHandler = pane.messageHandler
260 app.shortcutHandler = pane.shortcutHandler
261 app.panes = []*BrowserPane{pane}
262 app.activePane = pane
263
264 if app.workspace == nil {
265 app.workspace = NewWorkspaceManager(app, pane)
266 }
267
268 // Initialize window-level global shortcuts AFTER workspace is set up
269 if window := view.Window(); window != nil {
270 app.windowShortcutHandler = NewWindowShortcutHandler(window, app)
271 if app.windowShortcutHandler != nil {
272 log.Printf("Window-level global shortcuts initialized")
273 } else {
274 log.Printf("Warning: failed to initialize window-level shortcuts")
275 }
276 }
277
278 app.setupWebViewIntegration()
279
280 // Apply initial zoom and show window
281 app.zoomController.ApplyInitialZoom()
282
283 log.Printf("Showing WebView window…")
284 if err := view.Show(); err != nil {
285 log.Printf("Warning: failed to show WebView: %v", err)
286 } else if !webkit.IsNativeAvailable() {
287 log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
288 }
289
290 return nil
291}
292
293// setupWebViewIntegration connects the WebView to browser services
294func (app *BrowserApp) setupWebViewIntegration() {
295 app.browserService.AttachWebView(app.webView)
296
297 // GUI bundle is loaded via WebKit user scripts in enableUserContentManager
298 // No need to load separately in browser service
299
300 // Use native window as title updater
301 if win := app.webView.Window(); win != nil {
302 app.browserService.SetWindowTitleUpdater(win)
303 }
304}
305
306// setupContentBlocking initializes the content blocking system with proper timing
307func (app *BrowserApp) setupContentBlocking() error {
308 log.Printf("Initializing content blocking system...")
309
310 // Enable WebKit debug logging if requested
311 webkit.SetupWebKitDebugLogging(app.config)
312
313 defer func() {
314 if r := recover(); r != nil {
315 log.Printf("Content blocking setup panic recovered: %v", r)
316 }
317 }()
318
319 // Setup filter system
320 filterManager, err := filtering.SetupFilterSystem()
321 if err != nil {
322 log.Printf("Warning: Failed to setup filter system: %v", err)
323 return nil // Don't fail browser startup, continue without filters
324 }
325
326 // Store reference to filter manager
327 app.filterManager = filterManager
328
329 // Set up callback to re-apply network filters when they become ready
330 // This callback will be called from the async filter loading process
331 filterManager.SetFiltersReadyCallback(func() {
332 // Add small delay to avoid race conditions with WebView initialization
333 go func() {
334 time.Sleep(300 * time.Millisecond)
335 if app.webView != nil {
336 if err := app.webView.UpdateContentFilters(filterManager); err != nil {
337 log.Printf("Failed to apply filters after loading: %v", err)
338 } else {
339 log.Printf("Successfully applied filters after async loading")
340 }
341 }
342 }()
343 })
344
345 // Start async filter loading early (before WebView content blocking initialization)
346 // This allows filters to be ready when the WebView needs them
347 go func() {
348 if err := filtering.InitializeFiltersAsync(filterManager); err != nil {
349 log.Printf("Warning: failed to initialize filters asynchronously: %v", err)
350 }
351 }()
352
353 // Initialize content blocking in WebView with delay
354 // Wait for WebView to be fully loaded before setting up content blocking
355 go func() {
356 // Wait extra time for the first navigation to avoid preconnect interference
357 log.Printf("Waiting for WebView to complete initial load before enabling content blocking...")
358 time.Sleep(3000 * time.Millisecond) // 3 seconds for first load stability
359
360 if err := app.webView.InitializeContentBlocking(filterManager); err != nil {
361 log.Printf("Warning: Failed to initialize content blocking: %v", err)
362 // Continue without content blocking rather than failing
363 }
364
365 // Register navigation handler for domain-specific filtering and GUI injection
366 // Only register after content blocking is initialized
367 app.webView.RegisterURIChangedHandler(func(uri string) {
368 // Add small delay to avoid conflicts with page load
369 go func() {
370 time.Sleep(200 * time.Millisecond) // Slightly longer delay for stability
371
372 // Apply content filtering if available
373 if app.filterManager != nil {
374 app.webView.OnNavigate(uri, app.filterManager)
375 }
376
377 // Note: GUI bundle (controls and toast) is now injected as User Script
378 // in WebKit's enableUserContentManager, so it persists across all navigations
379 }()
380 })
381 }()
382
383 log.Printf("Content blocking system initialization started")
384 return nil
385}