1// Package filtering provides content blocking services for WebViews.
2package filtering
3
4import (
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.
19type 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
39type webViewState struct {
40 networkApplied bool
41 cosmeticApplied bool
42}
43
44// NewContentBlockingService creates a new content blocking service.
45func 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.
68func (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.
126func (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.)
161func (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.Debug(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.
208func (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.
219func (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.
234func (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.Debug(fmt.Sprintf("[filtering] Applied network filters to WebView %d", wv.ID()))
278}
279
280// setupCosmeticHooks sets up hooks for cosmetic filtering on navigation events.
281func (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.
293func (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.Debug(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.Debug(fmt.Sprintf("[filtering] Successfully injected cosmetic rules for domain: %s", domain))
327 }
328 })
329}
330
331// GetFilterManager returns the underlying filter manager.
332func (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.
339func (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.
346func (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.
353func (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}