detached 422848a

feat(filtering): add content blocking service for centralized WebView filter management

commit
422848a92d2230b4808fea2ced282a5108b94462
author
bnema · · 2025-11-29 11:26 UTC
parent
d2baffa
signature
GPG Unverified2B82207D370127A0 The key is unknown
6 files · +440 -21
M internal/app/browser/browser.go +10 -8
@@ -58,7 +58,8 @@ type BrowserApp struct {
5858 workspace *WorkspaceManager
5959
6060 // Content filtering
61- filterManager *filtering.FilterManager
61+ filterManager *filtering.FilterManager
62+ contentBlockingService *filtering.ContentBlockingService
6263
6364 // Handlers
6465 schemeHandler *api.SchemeHandler
@@ -235,7 +236,14 @@ func (app *BrowserApp) Run() {
235236 // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation)
236237 webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle)
237238
238- // Create and setup WebView
239+ // Initialize content blocking BEFORE creating WebViews so the service is ready
240+ // to register WebViews as they are created (tabs, workspaces, popups)
241+ if err := app.setupContentBlocking(); err != nil {
242+ log.Printf("Warning: failed to setup content blocking: %v", err)
243+ // Continue without content blocking
244+ }
245+
246+ // Create and setup WebView (this also creates TabManager which creates more WebViews)
239247 if err := app.createWebView(); err != nil {
240248 log.Printf("Warning: failed to create WebView: %v", err)
241249 return
@@ -246,12 +254,6 @@ func (app *BrowserApp) Run() {
246254 log.Printf("Warning: failed to register URI scheme handlers: %v", err)
247255 }
248256
249- // Initialize content blocking
250- if err := app.setupContentBlocking(); err != nil {
251- log.Printf("Warning: failed to setup content blocking: %v", err)
252- // Continue without content blocking
253- }
254-
255257 // Handle browse command if present (must use active tab's navigation controller)
256258 if app.tabManager != nil {
257259 activeTab := app.tabManager.GetActiveTab()
@@ -58,7 +58,8 @@ type BrowserApp struct {
58 workspace *WorkspaceManager58 workspace *WorkspaceManager
59 59
60 // Content filtering60 // Content filtering
61- filterManager *filtering.FilterManager61+ filterManager *filtering.FilterManager
62+ contentBlockingService *filtering.ContentBlockingService
62 63
63 // Handlers64 // Handlers
64 schemeHandler *api.SchemeHandler65 schemeHandler *api.SchemeHandler
@@ -235,7 +236,14 @@ func (app *BrowserApp) Run() {
235 // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation)236 // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation)
236 webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle)237 webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle)
237 238
238- // Create and setup WebView239+ // Initialize content blocking BEFORE creating WebViews so the service is ready
240+ // to register WebViews as they are created (tabs, workspaces, popups)
241+ if err := app.setupContentBlocking(); err != nil {
242+ log.Printf("Warning: failed to setup content blocking: %v", err)
243+ // Continue without content blocking
244+ }
245+
246+ // Create and setup WebView (this also creates TabManager which creates more WebViews)
239 if err := app.createWebView(); err != nil {247 if err := app.createWebView(); err != nil {
240 log.Printf("Warning: failed to create WebView: %v", err)248 log.Printf("Warning: failed to create WebView: %v", err)
241 return249 return
@@ -246,12 +254,6 @@ func (app *BrowserApp) Run() {
246 log.Printf("Warning: failed to register URI scheme handlers: %v", err)254 log.Printf("Warning: failed to register URI scheme handlers: %v", err)
247 }255 }
248 256
249- // Initialize content blocking
250- if err := app.setupContentBlocking(); err != nil {
251- log.Printf("Warning: failed to setup content blocking: %v", err)
252- // Continue without content blocking
253- }
254-
255 // Handle browse command if present (must use active tab's navigation controller)257 // Handle browse command if present (must use active tab's navigation controller)
256 if app.tabManager != nil {258 if app.tabManager != nil {
257 activeTab := app.tabManager.GetActiveTab()259 activeTab := app.tabManager.GetActiveTab()
M internal/app/browser/tab_manager.go +3 -0
@@ -237,6 +237,9 @@ func (tm *TabManager) createTabInternal(url string) error {
237237 return fmt.Errorf("failed to create webview: %w", err)
238238 }
239239
240+ // Register with content blocking service for network/cosmetic filtering
241+ tm.app.RegisterWebViewForFiltering(view)
242+
240243 // Create pane for this WebView
241244 pane, err := tm.app.createPaneForView(view)
242245 if err != nil {
@@ -237,6 +237,9 @@ func (tm *TabManager) createTabInternal(url string) error {
237 return fmt.Errorf("failed to create webview: %w", err)237 return fmt.Errorf("failed to create webview: %w", err)
238 }238 }
239 239
240+ // Register with content blocking service for network/cosmetic filtering
241+ tm.app.RegisterWebViewForFiltering(view)
242+
240 // Create pane for this WebView243 // Create pane for this WebView
241 pane, err := tm.app.createPaneForView(view)244 pane, err := tm.app.createPaneForView(view)
242 if err != nil {245 if err != nil {
M internal/app/browser/webview.go +34 -12
@@ -390,10 +390,25 @@ func (app *BrowserApp) setupContentBlocking() error {
390390 // Store reference to filter manager
391391 app.filterManager = filterManager
392392
393+ // Get data directory for content blocking service
394+ dataDir, err := config.GetDataDir()
395+ if err != nil {
396+ log.Printf("Warning: Failed to get data dir for content blocking: %v", err)
397+ return nil
398+ }
399+
400+ // Create content blocking service
401+ cbService, err := filtering.NewContentBlockingService(dataDir+"/webkit", filterManager)
402+ if err != nil {
403+ log.Printf("Warning: Failed to create content blocking service: %v", err)
404+ return nil
405+ }
406+ app.contentBlockingService = cbService
407+
393408 // Set up callback to apply network filters when they become ready
394409 // This callback will be called from the async filter loading process
395410 filterManager.SetFiltersReadyCallback(func() {
396- log.Printf("[filtering] Filters ready, applying to WebView...")
411+ log.Printf("[filtering] Filters ready, distributing to all WebViews...")
397412
398413 // Get the WebKit JSON rules from filter manager
399414 filterJSON, err := filterManager.GetNetworkFilters()
@@ -409,17 +424,9 @@ func (app *BrowserApp) setupContentBlocking() error {
409424
410425 log.Printf("[filtering] Got %d bytes of WebKit JSON rules", len(filterJSON))
411426
412- // Apply filters to the WebView
413- // This must be done on the main thread since it touches GTK/WebKit
414- if app.webView != nil {
415- app.webView.RunOnMainThread(func() {
416- if err := app.webView.InitializeContentBlocking(filterJSON); err != nil {
417- log.Printf("[filtering] Failed to apply content filters: %v", err)
418- } else {
419- log.Printf("[filtering] ✅ Content blocking enabled successfully")
420- }
421- })
422- }
427+ // Signal the service that filters are ready - it will apply to all registered WebViews
428+ cbService.SetFiltersReady(filterJSON)
429+ log.Printf("[filtering] Content blocking enabled for all WebViews")
423430 })
424431
425432 // Start async filter loading
@@ -433,3 +440,18 @@ func (app *BrowserApp) setupContentBlocking() error {
433440 log.Printf("Content blocking system initialization started")
434441 return nil
435442 }
443+
444+// RegisterWebViewForFiltering registers a WebView with the content blocking service.
445+// This should be called for every new WebView created in the application.
446+func (app *BrowserApp) RegisterWebViewForFiltering(wv *webkit.WebView) {
447+ if app.contentBlockingService != nil && wv != nil {
448+ app.contentBlockingService.RegisterWebView(wv)
449+ }
450+}
451+
452+// UnregisterWebViewFromFiltering removes a WebView from the content blocking service.
453+func (app *BrowserApp) UnregisterWebViewFromFiltering(wv *webkit.WebView) {
454+ if app.contentBlockingService != nil && wv != nil {
455+ app.contentBlockingService.UnregisterWebView(wv)
456+ }
457+}
@@ -390,10 +390,25 @@ func (app *BrowserApp) setupContentBlocking() error {
390 // Store reference to filter manager390 // Store reference to filter manager
391 app.filterManager = filterManager391 app.filterManager = filterManager
392 392
393+ // Get data directory for content blocking service
394+ dataDir, err := config.GetDataDir()
395+ if err != nil {
396+ log.Printf("Warning: Failed to get data dir for content blocking: %v", err)
397+ return nil
398+ }
399+
400+ // Create content blocking service
401+ cbService, err := filtering.NewContentBlockingService(dataDir+"/webkit", filterManager)
402+ if err != nil {
403+ log.Printf("Warning: Failed to create content blocking service: %v", err)
404+ return nil
405+ }
406+ app.contentBlockingService = cbService
407+
393 // Set up callback to apply network filters when they become ready408 // Set up callback to apply network filters when they become ready
394 // This callback will be called from the async filter loading process409 // This callback will be called from the async filter loading process
395 filterManager.SetFiltersReadyCallback(func() {410 filterManager.SetFiltersReadyCallback(func() {
396- log.Printf("[filtering] Filters ready, applying to WebView...")411+ log.Printf("[filtering] Filters ready, distributing to all WebViews...")
397 412
398 // Get the WebKit JSON rules from filter manager413 // Get the WebKit JSON rules from filter manager
399 filterJSON, err := filterManager.GetNetworkFilters()414 filterJSON, err := filterManager.GetNetworkFilters()
@@ -409,17 +424,9 @@ func (app *BrowserApp) setupContentBlocking() error {
409 424
410 log.Printf("[filtering] Got %d bytes of WebKit JSON rules", len(filterJSON))425 log.Printf("[filtering] Got %d bytes of WebKit JSON rules", len(filterJSON))
411 426
412- // Apply filters to the WebView427+ // Signal the service that filters are ready - it will apply to all registered WebViews
413- // This must be done on the main thread since it touches GTK/WebKit428+ cbService.SetFiltersReady(filterJSON)
414- if app.webView != nil {429+ log.Printf("[filtering] Content blocking enabled for all WebViews")
415- app.webView.RunOnMainThread(func() {
416- if err := app.webView.InitializeContentBlocking(filterJSON); err != nil {
417- log.Printf("[filtering] Failed to apply content filters: %v", err)
418- } else {
419- log.Printf("[filtering] ✅ Content blocking enabled successfully")
420- }
421- })
422- }
423 })430 })
424 431
425 // Start async filter loading432 // Start async filter loading
@@ -433,3 +440,18 @@ func (app *BrowserApp) setupContentBlocking() error {
433 log.Printf("Content blocking system initialization started")440 log.Printf("Content blocking system initialization started")
434 return nil441 return nil
435 }442 }
443+
444+// RegisterWebViewForFiltering registers a WebView with the content blocking service.
445+// This should be called for every new WebView created in the application.
446+func (app *BrowserApp) RegisterWebViewForFiltering(wv *webkit.WebView) {
447+ if app.contentBlockingService != nil && wv != nil {
448+ app.contentBlockingService.RegisterWebView(wv)
449+ }
450+}
451+
452+// UnregisterWebViewFromFiltering removes a WebView from the content blocking service.
453+func (app *BrowserApp) UnregisterWebViewFromFiltering(wv *webkit.WebView) {
454+ if app.contentBlockingService != nil && wv != nil {
455+ app.contentBlockingService.UnregisterWebView(wv)
456+ }
457+}
M internal/app/browser/workspace_manager.go +7 -1
@@ -150,7 +150,13 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
150150 // so they do not need their own toplevel GtkWindow. Setting CreateWindow=false
151151 // prevents GTK "widget already has parent" errors when adding to paned containers.
152152 cfg.CreateWindow = false
153- return webkit.NewWebView(cfg)
153+ view, err := webkit.NewWebView(cfg)
154+ if err != nil {
155+ return nil, err
156+ }
157+ // Register with content blocking service for network/cosmetic filtering
158+ manager.app.RegisterWebViewForFiltering(view)
159+ return view, nil
154160 }
155161 manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
156162 if manager.app == nil {
@@ -150,7 +150,13 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
150 // so they do not need their own toplevel GtkWindow. Setting CreateWindow=false150 // so they do not need their own toplevel GtkWindow. Setting CreateWindow=false
151 // prevents GTK "widget already has parent" errors when adding to paned containers.151 // prevents GTK "widget already has parent" errors when adding to paned containers.
152 cfg.CreateWindow = false152 cfg.CreateWindow = false
153- return webkit.NewWebView(cfg)153+ view, err := webkit.NewWebView(cfg)
154+ if err != nil {
155+ return nil, err
156+ }
157+ // Register with content blocking service for network/cosmetic filtering
158+ manager.app.RegisterWebViewForFiltering(view)
159+ return view, nil
154 }160 }
155 manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {161 manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
156 if manager.app == nil {162 if manager.app == nil {
M internal/app/browser/workspace_popup.go +6 -0
@@ -267,6 +267,9 @@ func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webVi
267267 return
268268 }
269269
270+ // Register with content blocking service for network/cosmetic filtering
271+ wm.app.RegisterWebViewForFiltering(independentView)
272+
270273 // The related webView was just for detection - we don't use it for tabs
271274 // Insert the new independent view as a tab
272275 if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {
@@ -398,6 +401,9 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
398401 return
399402 }
400403
404+ // Register with content blocking service for network/cosmetic filtering
405+ wm.app.RegisterWebViewForFiltering(wrappedView)
406+
401407 log.Printf("[workspace] Successfully initialized popup WebView ID %d", wrappedView.ID())
402408
403409 // Create pane for the fully initialized WebView
@@ -267,6 +267,9 @@ func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webVi
267 return267 return
268 }268 }
269 269
270+ // Register with content blocking service for network/cosmetic filtering
271+ wm.app.RegisterWebViewForFiltering(independentView)
272+
270 // The related webView was just for detection - we don't use it for tabs273 // The related webView was just for detection - we don't use it for tabs
271 // Insert the new independent view as a tab274 // Insert the new independent view as a tab
272 if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {275 if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {
@@ -398,6 +401,9 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
398 return401 return
399 }402 }
400 403
404+ // Register with content blocking service for network/cosmetic filtering
405+ wm.app.RegisterWebViewForFiltering(wrappedView)
406+
401 log.Printf("[workspace] Successfully initialized popup WebView ID %d", wrappedView.ID())407 log.Printf("[workspace] Successfully initialized popup WebView ID %d", wrappedView.ID())
402 408
403 // Create pane for the fully initialized WebView409 // Create pane for the fully initialized WebView
A internal/filtering/service.go +380 -0
@@ -0,0 +1,380 @@
1+// Package filtering provides content blocking services for WebViews.
2+package filtering
3+
4+import (
5+ "fmt"
6+ "net/url"
7+ "sync"
8+
9+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
10+
11+ "github.com/bnema/dumber/internal/filtering/cosmetic"
12+ "github.com/bnema/dumber/internal/logging"
13+ pkgwebkit "github.com/bnema/dumber/pkg/webkit"
14+)
15+
16+// ContentBlockingService manages content blocking for all WebViews.
17+// It ensures network filters and cosmetic rules are applied consistently
18+// across all WebViews in the application.
19+type ContentBlockingService struct {
20+ mu sync.RWMutex
21+
22+ // Filter data
23+ networkFilterJSON []byte
24+ filterManager *FilterManager
25+ cosmeticInjector *cosmetic.CosmeticInjector
26+ filtersReady bool
27+
28+ // WebView registry - tracks all active WebViews
29+ webViews map[*pkgwebkit.WebView]webViewState
30+
31+ // Content blocking manager for compiling filters
32+ contentBlockingMgr *pkgwebkit.ContentBlockingManager
33+
34+ // Data directory for filter storage
35+ dataDir string
36+}
37+
38+// webViewState tracks per-WebView filter application state
39+type webViewState struct {
40+ networkApplied bool
41+ cosmeticApplied bool
42+}
43+
44+// NewContentBlockingService creates a new content blocking service.
45+func NewContentBlockingService(dataDir string, filterManager *FilterManager) (*ContentBlockingService, error) {
46+ if filterManager == nil {
47+ return nil, fmt.Errorf("filterManager is required")
48+ }
49+
50+ cbm, err := pkgwebkit.NewContentBlockingManager(dataDir + "/content-filters")
51+ if err != nil {
52+ return nil, fmt.Errorf("failed to create content blocking manager: %w", err)
53+ }
54+
55+ service := &ContentBlockingService{
56+ filterManager: filterManager,
57+ cosmeticInjector: filterManager.cosmeticInjector,
58+ webViews: make(map[*pkgwebkit.WebView]webViewState),
59+ contentBlockingMgr: cbm,
60+ dataDir: dataDir,
61+ }
62+
63+ return service, nil
64+}
65+
66+// SetFiltersReady is called when filters have been parsed and are ready to compile.
67+// It compiles filters asynchronously, then applies to all registered WebViews.
68+func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) {
69+ s.mu.Lock()
70+ s.networkFilterJSON = networkJSON
71+ s.filtersReady = true // Cosmetic rules are ready immediately
72+ webViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
73+ for wv := range s.webViews {
74+ webViews = append(webViews, wv)
75+ }
76+ s.mu.Unlock()
77+
78+ // Inject cosmetic scripts IMMEDIATELY (don't wait for network filter compilation)
79+ // This hides ads visually within milliseconds while network filters compile in background
80+ for _, wv := range webViews {
81+ webView := wv // capture for closure
82+ pkgwebkit.RunOnMainThread(func() {
83+ s.injectCosmeticBaseScript(webView)
84+ if uri := webView.GetCurrentURL(); uri != "" {
85+ s.injectCosmeticRules(webView, uri)
86+ }
87+ })
88+ }
89+ logging.Info("[filtering] Cosmetic rules injected immediately to all WebViews")
90+
91+ if len(networkJSON) == 0 {
92+ logging.Warn("[filtering] SetFiltersReady called with empty network filter JSON")
93+ return
94+ }
95+
96+ // Compile network filters asynchronously - this happens in the background
97+ // and does not block the main thread (takes ~6 seconds)
98+ s.contentBlockingMgr.CompileFilters("adblock-filters", networkJSON, func(err error) {
99+ if err != nil {
100+ logging.Error(fmt.Sprintf("[filtering] Filter compilation failed: %v", err))
101+ return
102+ }
103+
104+ logging.Info("[filtering] Network filter compilation complete, applying to WebViews")
105+
106+ // Get current WebViews (may have changed since cosmetic injection)
107+ s.mu.RLock()
108+ currentWebViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
109+ for wv := range s.webViews {
110+ currentWebViews = append(currentWebViews, wv)
111+ }
112+ s.mu.RUnlock()
113+
114+ // Apply network filters to all registered WebViews
115+ for _, wv := range currentWebViews {
116+ webView := wv // capture for closure
117+ pkgwebkit.RunOnMainThread(func() {
118+ s.applyFiltersToWebView(webView)
119+ })
120+ }
121+ })
122+}
123+
124+// RegisterWebView registers a WebView for content blocking.
125+// If filters are already compiled, they will be applied immediately.
126+func (s *ContentBlockingService) RegisterWebView(wv *pkgwebkit.WebView) {
127+ if wv == nil {
128+ return
129+ }
130+
131+ s.mu.Lock()
132+ if _, exists := s.webViews[wv]; exists {
133+ s.mu.Unlock()
134+ return // Already registered
135+ }
136+ s.webViews[wv] = webViewState{}
137+ filtersReady := s.filtersReady
138+ s.mu.Unlock()
139+
140+ logging.Debug(fmt.Sprintf("[filtering] Registered WebView %d for content blocking", wv.ID()))
141+
142+ // Setup cosmetic filtering hooks for domain-specific rules (navigation handler)
143+ s.setupCosmeticHooks(wv)
144+
145+ // Only inject cosmetic base script if filters are loaded (rules available)
146+ // Otherwise, it will be injected when SetFiltersReady completes
147+ if filtersReady {
148+ s.injectCosmeticBaseScript(wv)
149+ }
150+
151+ // Apply network filters if already compiled (instant)
152+ if s.contentBlockingMgr.IsFilterCompiled() {
153+ pkgwebkit.RunOnMainThread(func() {
154+ s.applyFiltersToWebView(wv)
155+ })
156+ }
157+}
158+
159+// injectCosmeticBaseScript injects the base cosmetic filtering script into a WebView.
160+// This script sets up the cosmetic filtering infrastructure (MutationObserver, hide functions, etc.)
161+func (s *ContentBlockingService) injectCosmeticBaseScript(wv *pkgwebkit.WebView) {
162+ if wv == nil {
163+ return
164+ }
165+
166+ gtkView := wv.GetWebView()
167+ if gtkView == nil {
168+ return
169+ }
170+
171+ ucm := gtkView.UserContentManager()
172+ if ucm == nil {
173+ return
174+ }
175+
176+ s.mu.RLock()
177+ injector := s.cosmeticInjector
178+ s.mu.RUnlock()
179+
180+ if injector == nil || !injector.IsEnabled() {
181+ return
182+ }
183+
184+ // Get the base cosmetic script (the infrastructure without domain-specific rules)
185+ // Pass empty domain to get just the base script
186+ baseScript := injector.GetScriptForDomain("")
187+ if baseScript == "" {
188+ logging.Debug(fmt.Sprintf("[filtering] No cosmetic base script available for WebView %d", wv.ID()))
189+ return
190+ }
191+
192+ logging.Info(fmt.Sprintf("[filtering] Injecting cosmetic base script into WebView %d (%d bytes)", wv.ID(), len(baseScript)))
193+
194+ // Inject at document-end when DOM is ready for querySelector/MutationObserver
195+ userScript := webkit.NewUserScript(
196+ baseScript,
197+ webkit.UserContentInjectAllFrames,
198+ webkit.UserScriptInjectAtDocumentEnd,
199+ nil, // allowList
200+ nil, // blockList
201+ )
202+
203+ ucm.AddScript(userScript)
204+ logging.Debug(fmt.Sprintf("[filtering] Injected cosmetic base script into WebView %d", wv.ID()))
205+}
206+
207+// UnregisterWebView removes a WebView from the registry.
208+func (s *ContentBlockingService) UnregisterWebView(wv *pkgwebkit.WebView) {
209+ if wv == nil {
210+ return
211+ }
212+
213+ s.mu.Lock()
214+ delete(s.webViews, wv)
215+ s.mu.Unlock()
216+}
217+
218+// applyFiltersToWebView applies both network and cosmetic filters to a WebView.
219+func (s *ContentBlockingService) applyFiltersToWebView(wv *pkgwebkit.WebView) {
220+ s.mu.RLock()
221+ networkJSON := s.networkFilterJSON
222+ state := s.webViews[wv]
223+ s.mu.RUnlock()
224+
225+ // Apply network filters if not already applied
226+ if !state.networkApplied && len(networkJSON) > 0 {
227+ pkgwebkit.RunOnMainThread(func() {
228+ s.applyNetworkFilters(wv, networkJSON)
229+ })
230+ }
231+}
232+
233+// applyNetworkFilters applies network blocking rules to a WebView.
234+func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filterJSON []byte) {
235+ if wv == nil {
236+ return
237+ }
238+
239+ gtkView := wv.GetWebView()
240+ if gtkView == nil {
241+ return
242+ }
243+
244+ ucm := gtkView.UserContentManager()
245+ if ucm == nil {
246+ return
247+ }
248+
249+ // Use shared identifier so all WebViews reuse the same compiled filter
250+ const identifier = "adblock-filters"
251+
252+ // Try to apply cached compiled filter first (instant)
253+ if s.contentBlockingMgr.IsFilterCompiled() {
254+ if err := s.contentBlockingMgr.ApplyCompiledFilter(ucm); err != nil {
255+ logging.Error(fmt.Sprintf("[filtering] Failed to apply cached filter to WebView %d: %v", wv.ID(), err))
256+ return
257+ }
258+ } else if len(filterJSON) > 0 {
259+ // Fallback: compile if not yet compiled (first WebView)
260+ err := s.contentBlockingMgr.ApplyFiltersFromJSON(ucm, identifier, filterJSON)
261+ if err != nil {
262+ logging.Error(fmt.Sprintf("[filtering] Failed to apply network filters to WebView %d: %v", wv.ID(), err))
263+ return
264+ }
265+ } else {
266+ return
267+ }
268+
269+ // Mark as applied
270+ s.mu.Lock()
271+ if state, exists := s.webViews[wv]; exists {
272+ state.networkApplied = true
273+ s.webViews[wv] = state
274+ }
275+ s.mu.Unlock()
276+
277+ logging.Info(fmt.Sprintf("[filtering] Applied network filters to WebView %d", wv.ID()))
278+}
279+
280+// setupCosmeticHooks sets up hooks for cosmetic filtering on navigation events.
281+func (s *ContentBlockingService) setupCosmeticHooks(wv *pkgwebkit.WebView) {
282+ if wv == nil {
283+ return
284+ }
285+
286+ // Register load-committed handler to inject domain-specific cosmetic rules
287+ wv.RegisterLoadCommittedHandler(func(uri string) {
288+ s.injectCosmeticRules(wv, uri)
289+ })
290+}
291+
292+// injectCosmeticRules injects cosmetic filtering rules for the current page.
293+func (s *ContentBlockingService) injectCosmeticRules(wv *pkgwebkit.WebView, uri string) {
294+ if wv == nil || uri == "" {
295+ return
296+ }
297+
298+ // Parse domain from URI
299+ parsedURL, err := url.Parse(uri)
300+ if err != nil || parsedURL.Host == "" {
301+ return
302+ }
303+
304+ domain := parsedURL.Hostname()
305+
306+ // Get cosmetic script for this domain
307+ s.mu.RLock()
308+ injector := s.cosmeticInjector
309+ s.mu.RUnlock()
310+
311+ if injector == nil || !injector.IsEnabled() {
312+ return
313+ }
314+
315+ script := injector.GetScriptForDomain(domain)
316+ if script == "" {
317+ return
318+ }
319+
320+ // Inject the script
321+ logging.Info(fmt.Sprintf("[filtering] Injecting cosmetic rules for domain: %s (%d bytes)", domain, len(script)))
322+ pkgwebkit.RunOnMainThread(func() {
323+ if err := wv.InjectScript(script); err != nil {
324+ logging.Error(fmt.Sprintf("[filtering] Failed to inject cosmetic rules for %s: %v", domain, err))
325+ } else {
326+ logging.Info(fmt.Sprintf("[filtering] Successfully injected cosmetic rules for domain: %s", domain))
327+ }
328+ })
329+}
330+
331+// GetFilterManager returns the underlying filter manager.
332+func (s *ContentBlockingService) GetFilterManager() *FilterManager {
333+ s.mu.RLock()
334+ defer s.mu.RUnlock()
335+ return s.filterManager
336+}
337+
338+// GetCosmeticInjector returns the cosmetic injector.
339+func (s *ContentBlockingService) GetCosmeticInjector() *cosmetic.CosmeticInjector {
340+ s.mu.RLock()
341+ defer s.mu.RUnlock()
342+ return s.cosmeticInjector
343+}
344+
345+// IsReady returns whether filters have been loaded and are ready to apply.
346+func (s *ContentBlockingService) IsReady() bool {
347+ s.mu.RLock()
348+ defer s.mu.RUnlock()
349+ return s.filtersReady
350+}
351+
352+// GetStats returns statistics about the content blocking service.
353+func (s *ContentBlockingService) GetStats() map[string]interface{} {
354+ s.mu.RLock()
355+ defer s.mu.RUnlock()
356+
357+ webViewCount := len(s.webViews)
358+ appliedCount := 0
359+ for _, state := range s.webViews {
360+ if state.networkApplied {
361+ appliedCount++
362+ }
363+ }
364+
365+ stats := map[string]interface{}{
366+ "ready": s.filtersReady,
367+ "webview_count": webViewCount,
368+ "filters_applied": appliedCount,
369+ "network_rules_bytes": len(s.networkFilterJSON),
370+ }
371+
372+ if s.cosmeticInjector != nil {
373+ cosmeticStats := s.cosmeticInjector.GetStats()
374+ for k, v := range cosmeticStats {
375+ stats["cosmetic_"+k] = v
376+ }
377+ }
378+
379+ return stats
380+}
@@ -0,0 +1,380 @@
1+// Package filtering provides content blocking services for WebViews.
2+package filtering
3+
4+import (
5+ "fmt"
6+ "net/url"
7+ "sync"
8+
9+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
10+
11+ "github.com/bnema/dumber/internal/filtering/cosmetic"
12+ "github.com/bnema/dumber/internal/logging"
13+ pkgwebkit "github.com/bnema/dumber/pkg/webkit"
14+)
15+
16+// ContentBlockingService manages content blocking for all WebViews.
17+// It ensures network filters and cosmetic rules are applied consistently
18+// across all WebViews in the application.
19+type ContentBlockingService struct {
20+ mu sync.RWMutex
21+
22+ // Filter data
23+ networkFilterJSON []byte
24+ filterManager *FilterManager
25+ cosmeticInjector *cosmetic.CosmeticInjector
26+ filtersReady bool
27+
28+ // WebView registry - tracks all active WebViews
29+ webViews map[*pkgwebkit.WebView]webViewState
30+
31+ // Content blocking manager for compiling filters
32+ contentBlockingMgr *pkgwebkit.ContentBlockingManager
33+
34+ // Data directory for filter storage
35+ dataDir string
36+}
37+
38+// webViewState tracks per-WebView filter application state
39+type webViewState struct {
40+ networkApplied bool
41+ cosmeticApplied bool
42+}
43+
44+// NewContentBlockingService creates a new content blocking service.
45+func NewContentBlockingService(dataDir string, filterManager *FilterManager) (*ContentBlockingService, error) {
46+ if filterManager == nil {
47+ return nil, fmt.Errorf("filterManager is required")
48+ }
49+
50+ cbm, err := pkgwebkit.NewContentBlockingManager(dataDir + "/content-filters")
51+ if err != nil {
52+ return nil, fmt.Errorf("failed to create content blocking manager: %w", err)
53+ }
54+
55+ service := &ContentBlockingService{
56+ filterManager: filterManager,
57+ cosmeticInjector: filterManager.cosmeticInjector,
58+ webViews: make(map[*pkgwebkit.WebView]webViewState),
59+ contentBlockingMgr: cbm,
60+ dataDir: dataDir,
61+ }
62+
63+ return service, nil
64+}
65+
66+// SetFiltersReady is called when filters have been parsed and are ready to compile.
67+// It compiles filters asynchronously, then applies to all registered WebViews.
68+func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) {
69+ s.mu.Lock()
70+ s.networkFilterJSON = networkJSON
71+ s.filtersReady = true // Cosmetic rules are ready immediately
72+ webViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
73+ for wv := range s.webViews {
74+ webViews = append(webViews, wv)
75+ }
76+ s.mu.Unlock()
77+
78+ // Inject cosmetic scripts IMMEDIATELY (don't wait for network filter compilation)
79+ // This hides ads visually within milliseconds while network filters compile in background
80+ for _, wv := range webViews {
81+ webView := wv // capture for closure
82+ pkgwebkit.RunOnMainThread(func() {
83+ s.injectCosmeticBaseScript(webView)
84+ if uri := webView.GetCurrentURL(); uri != "" {
85+ s.injectCosmeticRules(webView, uri)
86+ }
87+ })
88+ }
89+ logging.Info("[filtering] Cosmetic rules injected immediately to all WebViews")
90+
91+ if len(networkJSON) == 0 {
92+ logging.Warn("[filtering] SetFiltersReady called with empty network filter JSON")
93+ return
94+ }
95+
96+ // Compile network filters asynchronously - this happens in the background
97+ // and does not block the main thread (takes ~6 seconds)
98+ s.contentBlockingMgr.CompileFilters("adblock-filters", networkJSON, func(err error) {
99+ if err != nil {
100+ logging.Error(fmt.Sprintf("[filtering] Filter compilation failed: %v", err))
101+ return
102+ }
103+
104+ logging.Info("[filtering] Network filter compilation complete, applying to WebViews")
105+
106+ // Get current WebViews (may have changed since cosmetic injection)
107+ s.mu.RLock()
108+ currentWebViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
109+ for wv := range s.webViews {
110+ currentWebViews = append(currentWebViews, wv)
111+ }
112+ s.mu.RUnlock()
113+
114+ // Apply network filters to all registered WebViews
115+ for _, wv := range currentWebViews {
116+ webView := wv // capture for closure
117+ pkgwebkit.RunOnMainThread(func() {
118+ s.applyFiltersToWebView(webView)
119+ })
120+ }
121+ })
122+}
123+
124+// RegisterWebView registers a WebView for content blocking.
125+// If filters are already compiled, they will be applied immediately.
126+func (s *ContentBlockingService) RegisterWebView(wv *pkgwebkit.WebView) {
127+ if wv == nil {
128+ return
129+ }
130+
131+ s.mu.Lock()
132+ if _, exists := s.webViews[wv]; exists {
133+ s.mu.Unlock()
134+ return // Already registered
135+ }
136+ s.webViews[wv] = webViewState{}
137+ filtersReady := s.filtersReady
138+ s.mu.Unlock()
139+
140+ logging.Debug(fmt.Sprintf("[filtering] Registered WebView %d for content blocking", wv.ID()))
141+
142+ // Setup cosmetic filtering hooks for domain-specific rules (navigation handler)
143+ s.setupCosmeticHooks(wv)
144+
145+ // Only inject cosmetic base script if filters are loaded (rules available)
146+ // Otherwise, it will be injected when SetFiltersReady completes
147+ if filtersReady {
148+ s.injectCosmeticBaseScript(wv)
149+ }
150+
151+ // Apply network filters if already compiled (instant)
152+ if s.contentBlockingMgr.IsFilterCompiled() {
153+ pkgwebkit.RunOnMainThread(func() {
154+ s.applyFiltersToWebView(wv)
155+ })
156+ }
157+}
158+
159+// injectCosmeticBaseScript injects the base cosmetic filtering script into a WebView.
160+// This script sets up the cosmetic filtering infrastructure (MutationObserver, hide functions, etc.)
161+func (s *ContentBlockingService) injectCosmeticBaseScript(wv *pkgwebkit.WebView) {
162+ if wv == nil {
163+ return
164+ }
165+
166+ gtkView := wv.GetWebView()
167+ if gtkView == nil {
168+ return
169+ }
170+
171+ ucm := gtkView.UserContentManager()
172+ if ucm == nil {
173+ return
174+ }
175+
176+ s.mu.RLock()
177+ injector := s.cosmeticInjector
178+ s.mu.RUnlock()
179+
180+ if injector == nil || !injector.IsEnabled() {
181+ return
182+ }
183+
184+ // Get the base cosmetic script (the infrastructure without domain-specific rules)
185+ // Pass empty domain to get just the base script
186+ baseScript := injector.GetScriptForDomain("")
187+ if baseScript == "" {
188+ logging.Debug(fmt.Sprintf("[filtering] No cosmetic base script available for WebView %d", wv.ID()))
189+ return
190+ }
191+
192+ logging.Info(fmt.Sprintf("[filtering] Injecting cosmetic base script into WebView %d (%d bytes)", wv.ID(), len(baseScript)))
193+
194+ // Inject at document-end when DOM is ready for querySelector/MutationObserver
195+ userScript := webkit.NewUserScript(
196+ baseScript,
197+ webkit.UserContentInjectAllFrames,
198+ webkit.UserScriptInjectAtDocumentEnd,
199+ nil, // allowList
200+ nil, // blockList
201+ )
202+
203+ ucm.AddScript(userScript)
204+ logging.Debug(fmt.Sprintf("[filtering] Injected cosmetic base script into WebView %d", wv.ID()))
205+}
206+
207+// UnregisterWebView removes a WebView from the registry.
208+func (s *ContentBlockingService) UnregisterWebView(wv *pkgwebkit.WebView) {
209+ if wv == nil {
210+ return
211+ }
212+
213+ s.mu.Lock()
214+ delete(s.webViews, wv)
215+ s.mu.Unlock()
216+}
217+
218+// applyFiltersToWebView applies both network and cosmetic filters to a WebView.
219+func (s *ContentBlockingService) applyFiltersToWebView(wv *pkgwebkit.WebView) {
220+ s.mu.RLock()
221+ networkJSON := s.networkFilterJSON
222+ state := s.webViews[wv]
223+ s.mu.RUnlock()
224+
225+ // Apply network filters if not already applied
226+ if !state.networkApplied && len(networkJSON) > 0 {
227+ pkgwebkit.RunOnMainThread(func() {
228+ s.applyNetworkFilters(wv, networkJSON)
229+ })
230+ }
231+}
232+
233+// applyNetworkFilters applies network blocking rules to a WebView.
234+func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filterJSON []byte) {
235+ if wv == nil {
236+ return
237+ }
238+
239+ gtkView := wv.GetWebView()
240+ if gtkView == nil {
241+ return
242+ }
243+
244+ ucm := gtkView.UserContentManager()
245+ if ucm == nil {
246+ return
247+ }
248+
249+ // Use shared identifier so all WebViews reuse the same compiled filter
250+ const identifier = "adblock-filters"
251+
252+ // Try to apply cached compiled filter first (instant)
253+ if s.contentBlockingMgr.IsFilterCompiled() {
254+ if err := s.contentBlockingMgr.ApplyCompiledFilter(ucm); err != nil {
255+ logging.Error(fmt.Sprintf("[filtering] Failed to apply cached filter to WebView %d: %v", wv.ID(), err))
256+ return
257+ }
258+ } else if len(filterJSON) > 0 {
259+ // Fallback: compile if not yet compiled (first WebView)
260+ err := s.contentBlockingMgr.ApplyFiltersFromJSON(ucm, identifier, filterJSON)
261+ if err != nil {
262+ logging.Error(fmt.Sprintf("[filtering] Failed to apply network filters to WebView %d: %v", wv.ID(), err))
263+ return
264+ }
265+ } else {
266+ return
267+ }
268+
269+ // Mark as applied
270+ s.mu.Lock()
271+ if state, exists := s.webViews[wv]; exists {
272+ state.networkApplied = true
273+ s.webViews[wv] = state
274+ }
275+ s.mu.Unlock()
276+
277+ logging.Info(fmt.Sprintf("[filtering] Applied network filters to WebView %d", wv.ID()))
278+}
279+
280+// setupCosmeticHooks sets up hooks for cosmetic filtering on navigation events.
281+func (s *ContentBlockingService) setupCosmeticHooks(wv *pkgwebkit.WebView) {
282+ if wv == nil {
283+ return
284+ }
285+
286+ // Register load-committed handler to inject domain-specific cosmetic rules
287+ wv.RegisterLoadCommittedHandler(func(uri string) {
288+ s.injectCosmeticRules(wv, uri)
289+ })
290+}
291+
292+// injectCosmeticRules injects cosmetic filtering rules for the current page.
293+func (s *ContentBlockingService) injectCosmeticRules(wv *pkgwebkit.WebView, uri string) {
294+ if wv == nil || uri == "" {
295+ return
296+ }
297+
298+ // Parse domain from URI
299+ parsedURL, err := url.Parse(uri)
300+ if err != nil || parsedURL.Host == "" {
301+ return
302+ }
303+
304+ domain := parsedURL.Hostname()
305+
306+ // Get cosmetic script for this domain
307+ s.mu.RLock()
308+ injector := s.cosmeticInjector
309+ s.mu.RUnlock()
310+
311+ if injector == nil || !injector.IsEnabled() {
312+ return
313+ }
314+
315+ script := injector.GetScriptForDomain(domain)
316+ if script == "" {
317+ return
318+ }
319+
320+ // Inject the script
321+ logging.Info(fmt.Sprintf("[filtering] Injecting cosmetic rules for domain: %s (%d bytes)", domain, len(script)))
322+ pkgwebkit.RunOnMainThread(func() {
323+ if err := wv.InjectScript(script); err != nil {
324+ logging.Error(fmt.Sprintf("[filtering] Failed to inject cosmetic rules for %s: %v", domain, err))
325+ } else {
326+ logging.Info(fmt.Sprintf("[filtering] Successfully injected cosmetic rules for domain: %s", domain))
327+ }
328+ })
329+}
330+
331+// GetFilterManager returns the underlying filter manager.
332+func (s *ContentBlockingService) GetFilterManager() *FilterManager {
333+ s.mu.RLock()
334+ defer s.mu.RUnlock()
335+ return s.filterManager
336+}
337+
338+// GetCosmeticInjector returns the cosmetic injector.
339+func (s *ContentBlockingService) GetCosmeticInjector() *cosmetic.CosmeticInjector {
340+ s.mu.RLock()
341+ defer s.mu.RUnlock()
342+ return s.cosmeticInjector
343+}
344+
345+// IsReady returns whether filters have been loaded and are ready to apply.
346+func (s *ContentBlockingService) IsReady() bool {
347+ s.mu.RLock()
348+ defer s.mu.RUnlock()
349+ return s.filtersReady
350+}
351+
352+// GetStats returns statistics about the content blocking service.
353+func (s *ContentBlockingService) GetStats() map[string]interface{} {
354+ s.mu.RLock()
355+ defer s.mu.RUnlock()
356+
357+ webViewCount := len(s.webViews)
358+ appliedCount := 0
359+ for _, state := range s.webViews {
360+ if state.networkApplied {
361+ appliedCount++
362+ }
363+ }
364+
365+ stats := map[string]interface{}{
366+ "ready": s.filtersReady,
367+ "webview_count": webViewCount,
368+ "filters_applied": appliedCount,
369+ "network_rules_bytes": len(s.networkFilterJSON),
370+ }
371+
372+ if s.cosmeticInjector != nil {
373+ cosmeticStats := s.cosmeticInjector.GetStats()
374+ for k, v := range cosmeticStats {
375+ stats["cosmetic_"+k] = v
376+ }
377+ }
378+
379+ return stats
380+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close