v0.15.0 9006957

service.go RawHistoryFolder

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}

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