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 (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
49 dataDir, err := config.GetDataDir()
50 if err != nil {
51 return nil, err
52 }
53 stateDir, err := config.GetStateDir()
54 if err != nil {
55 return nil, err
56 }
57
58 webkitData := filepath.Join(dataDir, "webkit")
59 webkitCache := filepath.Join(stateDir, "webkit-cache")
60 if err := os.MkdirAll(webkitData, constants.DirPerm); err != nil {
61 return nil, err
62 }
63 if err := os.MkdirAll(webkitCache, constants.DirPerm); err != nil {
64 return nil, err
65 }
66
67 cfg := &webkit.Config{
68 Assets: app.assets,
69 InitialURL: "dumb://homepage",
70 ZoomDefault: 1.0,
71 EnableDeveloperExtras: true,
72 DataDir: webkitData,
73 CacheDir: webkitCache,
74 APIToken: app.config.APISecurity.Token,
75 DefaultSansFont: app.config.Appearance.SansFont,
76 DefaultSerifFont: app.config.Appearance.SerifFont,
77 DefaultMonospaceFont: app.config.Appearance.MonospaceFont,
78 DefaultFontSize: app.config.Appearance.DefaultFontSize,
79 Rendering: webkit.RenderingConfig{Mode: string(app.config.RenderingMode)},
80 UseDomZoom: app.config.UseDomZoom,
81 VideoAcceleration: webkit.VideoAccelerationConfig{
82 EnableVAAPI: app.config.VideoAcceleration.EnableVAAPI,
83 AutoDetectGPU: app.config.VideoAcceleration.AutoDetectGPU,
84 VAAPIDriverName: app.config.VideoAcceleration.VAAPIDriverName,
85 EnableAllDrivers: app.config.VideoAcceleration.EnableAllDrivers,
86 LegacyVAAPI: app.config.VideoAcceleration.LegacyVAAPI,
87 },
88 Memory: buildMemoryConfig(app.config.WebkitMemory),
89 CodecPreferences: webkit.CodecPreferencesConfig{
90 PreferredCodecs: strings.Split(app.config.CodecPreferences.PreferredCodecs, ","),
91 BlockedCodecs: environment.BuildBlockedCodecsList(app.config.CodecPreferences),
92 ForceAV1: app.config.CodecPreferences.ForceAV1,
93 CustomUserAgent: app.config.CodecPreferences.CustomUserAgent,
94 DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl,
95 },
96 }
97
98 return cfg, nil
99}
100
101func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
102 pane := &BrowserPane{
103 webView: view,
104 }
105
106 pane.clipboardController = control.NewClipboardController(view)
107 pane.zoomController = control.NewZoomController(app.browserService, view)
108 pane.navigationController = control.NewNavigationController(
109 app.parserService,
110 app.browserService,
111 view,
112 pane.zoomController,
113 )
114 pane.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
115 pane.messageHandler.SetWebView(view)
116 pane.messageHandler.SetNavigationController(pane.navigationController)
117 pane.shortcutHandler = NewShortcutHandler(view, pane.clipboardController, app.config, app)
118
119 pane.zoomController.RegisterHandlers()
120 pane.shortcutHandler.RegisterShortcuts()
121
122 return pane, nil
123}
124
125func (app *BrowserApp) createPaneForView(view *webkit.WebView) (*BrowserPane, error) {
126 pane, err := app.buildPane(view)
127 if err != nil {
128 return nil, err
129 }
130
131 // Generate unique pane ID
132 pane.SetID(fmt.Sprintf("pane-%d-%p", time.Now().Unix(), view))
133 pane.initializeGUITracking()
134
135 // Inject minimal bootstrap script for pane initialization
136 bootstrapScript := fmt.Sprintf(`
137 window.__dumber_pane = {
138 id: '%s',
139 created: %d,
140 active: false
141 };
142
143 // GUI manager will be loaded on-demand
144 window.__dumber_gui_ready = true;
145
146 console.log('[pane] Initialized pane %s');
147 `, pane.ID(), time.Now().UnixMilli(), pane.ID())
148
149 if err := view.InjectScript(bootstrapScript); err != nil {
150 log.Printf("[pane-%s] Failed to inject bootstrap: %v", pane.ID(), err)
151 }
152
153 app.attachPaneHandlers(pane)
154 return pane, nil
155}
156
157func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
158 if pane == nil || pane.webView == nil {
159 return
160 }
161
162 pane.webView.RegisterTitleChangedHandler(func(title string) {
163 if title == "" {
164 return
165 }
166 url := pane.webView.GetCurrentURL()
167 if url == "" {
168 return
169 }
170 go func(url, title string) {
171 ctx := context.Background()
172 if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil {
173 log.Printf("Warning: failed to update page title: %v", err)
174 }
175 }(url, title)
176 })
177
178 pane.webView.RegisterScriptMessageHandler(func(payload string) {
179 if app.workspace != nil {
180 app.workspace.focusByView(pane.webView)
181 }
182 pane.messageHandler.Handle(payload)
183 })
184
185 if pane.messageHandler != nil && pane.navigationController != nil {
186 pane.messageHandler.SetNavigationController(pane.navigationController)
187 }
188
189 pane.webView.RegisterPopupHandler(func(uri string) *webkit.WebView {
190 if app.workspace != nil {
191 return app.workspace.HandlePopup(pane.webView, uri)
192 }
193
194 // No workspace manager - just navigate in current pane
195 if uri != "" && pane.navigationController != nil {
196 go func() {
197 if err := pane.navigationController.NavigateToURL(uri); err != nil {
198 log.Printf("[webview] failed to navigate to URL %s: %v", uri, err)
199 }
200 }()
201 }
202 return nil
203 })
204}
205
206// createWebView creates and configures the WebView
207func (app *BrowserApp) createWebView() error {
208 log.Printf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable())
209
210 cfg, err := app.buildWebkitConfig()
211 if err != nil {
212 return err
213 }
214
215 // Main window needs a top-level window
216 cfg.CreateWindow = true
217
218 view, err := webkit.NewWebView(cfg)
219
220 if err != nil {
221 return err
222 }
223
224 pane, err := app.createPaneForView(view)
225 if err != nil {
226 return err
227 }
228
229 app.webView = view
230 app.zoomController = pane.zoomController
231 app.navigationController = pane.navigationController
232 app.clipboardController = pane.clipboardController
233 app.messageHandler = pane.messageHandler
234 app.shortcutHandler = pane.shortcutHandler
235 app.panes = []*BrowserPane{pane}
236 app.activePane = pane
237
238 if app.workspace == nil {
239 app.workspace = NewWorkspaceManager(app, pane)
240 }
241
242 // Initialize window-level global shortcuts AFTER workspace is set up
243 if window := view.Window(); window != nil {
244 app.windowShortcutHandler = NewWindowShortcutHandler(window, app)
245 if app.windowShortcutHandler != nil {
246 log.Printf("Window-level global shortcuts initialized")
247 } else {
248 log.Printf("Warning: failed to initialize window-level shortcuts")
249 }
250 }
251
252 app.setupWebViewIntegration()
253
254 // Apply initial zoom and show window
255 app.zoomController.ApplyInitialZoom()
256
257 log.Printf("Showing WebView window…")
258 if err := view.Show(); err != nil {
259 log.Printf("Warning: failed to show WebView: %v", err)
260 } else if !webkit.IsNativeAvailable() {
261 log.Printf("Notice: running without webkit_cgo tag — no native window will be displayed.")
262 }
263
264 return nil
265}
266
267// setupWebViewIntegration connects the WebView to browser services
268func (app *BrowserApp) setupWebViewIntegration() {
269 app.browserService.AttachWebView(app.webView)
270
271 // GUI bundle is loaded via WebKit user scripts in enableUserContentManager
272 // No need to load separately in browser service
273
274 // Use native window as title updater
275 if win := app.webView.Window(); win != nil {
276 app.browserService.SetWindowTitleUpdater(win)
277 }
278}
279
280// setupContentBlocking initializes the content blocking system with proper timing
281func (app *BrowserApp) setupContentBlocking() error {
282 log.Printf("Initializing content blocking system...")
283
284 // Enable WebKit debug logging if requested
285 webkit.SetupWebKitDebugLogging(app.config)
286
287 defer func() {
288 if r := recover(); r != nil {
289 log.Printf("Content blocking setup panic recovered: %v", r)
290 }
291 }()
292
293 // Setup filter system
294 filterManager, err := filtering.SetupFilterSystem()
295 if err != nil {
296 log.Printf("Warning: Failed to setup filter system: %v", err)
297 return nil // Don't fail browser startup, continue without filters
298 }
299
300 // Store reference to filter manager
301 app.filterManager = filterManager
302
303 // Set up callback to re-apply network filters when they become ready
304 // This callback will be called from the async filter loading process
305 filterManager.SetFiltersReadyCallback(func() {
306 // Add small delay to avoid race conditions with WebView initialization
307 go func() {
308 time.Sleep(300 * time.Millisecond)
309 if app.webView != nil {
310 if err := app.webView.UpdateContentFilters(filterManager); err != nil {
311 log.Printf("Failed to apply filters after loading: %v", err)
312 } else {
313 log.Printf("Successfully applied filters after async loading")
314 }
315 }
316 }()
317 })
318
319 // Start async filter loading early (before WebView content blocking initialization)
320 // This allows filters to be ready when the WebView needs them
321 go func() {
322 if err := filtering.InitializeFiltersAsync(filterManager); err != nil {
323 log.Printf("Warning: failed to initialize filters asynchronously: %v", err)
324 }
325 }()
326
327 // Initialize content blocking in WebView with delay
328 // Wait for WebView to be fully loaded before setting up content blocking
329 go func() {
330 // Wait extra time for the first navigation to avoid preconnect interference
331 log.Printf("Waiting for WebView to complete initial load before enabling content blocking...")
332 time.Sleep(3000 * time.Millisecond) // 3 seconds for first load stability
333
334 if err := app.webView.InitializeContentBlocking(filterManager); err != nil {
335 log.Printf("Warning: Failed to initialize content blocking: %v", err)
336 // Continue without content blocking rather than failing
337 }
338
339 // Register navigation handler for domain-specific filtering and GUI injection
340 // Only register after content blocking is initialized
341 app.webView.RegisterURIChangedHandler(func(uri string) {
342 // Add small delay to avoid conflicts with page load
343 go func() {
344 time.Sleep(200 * time.Millisecond) // Slightly longer delay for stability
345
346 // Apply content filtering if available
347 if app.filterManager != nil {
348 app.webView.OnNavigate(uri, app.filterManager)
349 }
350
351 // Note: GUI bundle (controls and toast) is now injected as User Script
352 // in WebKit's enableUserContentManager, so it persists across all navigations
353 }()
354 })
355 }()
356
357 log.Printf("Content blocking system initialization started")
358 return nil
359}