detached e2562b2

wip

commit
e2562b2b93894cb4c7855d2afb44fa0e8444433a
author
bnema · · 2025-12-09 19:39 UTC
parent
1f794d3
signature
unsigned
8 files · +254 -328
M internal/cli/purge.go +9 -2
@@ -54,7 +54,7 @@ Available purge targets:
5454 --browser-cache, -b Purge WebKit browser cache (cached images, files, etc.)
5555 --browser-data, -B Purge WebKit browser data (localStorage, sessionStorage, IndexedDB)
5656 Note: Cookies are in cookies.db and deleted via --database
57- --filter-cache, -F Purge compiled ad blocking filters cache
57+ --filter-cache, -F Purge compiled ad blocking filters cache (EasyList + WebKit compiled)
5858 --state, -s Purge all state data (includes databases and caches)
5959 --config, -c Purge configuration files
6060 --all, -a Purge everything (default if no specific flags are provided)
@@ -217,7 +217,14 @@ func getPurgePaths(items []string) (map[string][]string, error) {
217217 if err != nil {
218218 return nil, fmt.Errorf("failed to get filter cache directory: %w", err)
219219 }
220- paths[item] = []string{filterCacheDir}
220+ // Also include WebKit compiled content-filters directory
221+ // WebKit stores data under {dataDir}/webkit/ subdirectory
222+ dataDir, err := config.GetDataDir()
223+ if err != nil {
224+ return nil, fmt.Errorf("failed to get data directory: %w", err)
225+ }
226+ contentFiltersDir := filepath.Join(dataDir, "webkit", "content-filters")
227+ paths[item] = []string{filterCacheDir, contentFiltersDir}
221228
222229 case "config":
223230 configFile, err := config.GetConfigFile()
@@ -54,7 +54,7 @@ Available purge targets:
54 --browser-cache, -b Purge WebKit browser cache (cached images, files, etc.)54 --browser-cache, -b Purge WebKit browser cache (cached images, files, etc.)
55 --browser-data, -B Purge WebKit browser data (localStorage, sessionStorage, IndexedDB)55 --browser-data, -B Purge WebKit browser data (localStorage, sessionStorage, IndexedDB)
56 Note: Cookies are in cookies.db and deleted via --database56 Note: Cookies are in cookies.db and deleted via --database
57- --filter-cache, -F Purge compiled ad blocking filters cache57+ --filter-cache, -F Purge compiled ad blocking filters cache (EasyList + WebKit compiled)
58 --state, -s Purge all state data (includes databases and caches)58 --state, -s Purge all state data (includes databases and caches)
59 --config, -c Purge configuration files59 --config, -c Purge configuration files
60 --all, -a Purge everything (default if no specific flags are provided)60 --all, -a Purge everything (default if no specific flags are provided)
@@ -217,7 +217,14 @@ func getPurgePaths(items []string) (map[string][]string, error) {
217 if err != nil {217 if err != nil {
218 return nil, fmt.Errorf("failed to get filter cache directory: %w", err)218 return nil, fmt.Errorf("failed to get filter cache directory: %w", err)
219 }219 }
220- paths[item] = []string{filterCacheDir}220+ // Also include WebKit compiled content-filters directory
221+ // WebKit stores data under {dataDir}/webkit/ subdirectory
222+ dataDir, err := config.GetDataDir()
223+ if err != nil {
224+ return nil, fmt.Errorf("failed to get data directory: %w", err)
225+ }
226+ contentFiltersDir := filepath.Join(dataDir, "webkit", "content-filters")
227+ paths[item] = []string{filterCacheDir, contentFiltersDir}
221 228
222 case "config":229 case "config":
223 configFile, err := config.GetConfigFile()230 configFile, err := config.GetConfigFile()
M internal/filtering/converter/easylist_to_webkit.go +24 -0
@@ -132,6 +132,9 @@ func (fc *FilterConverter) parseNetworkFilter(line string) error {
132132 },
133133 }
134134
135+ // Track if resource type was explicitly set
136+ hasExplicitResourceType := false
137+
135138 // Parse options
136139 for _, opt := range options {
137140 opt = strings.TrimSpace(opt)
@@ -160,18 +163,39 @@ func (fc *FilterConverter) parseNetworkFilter(line string) error {
160163 }
161164 case opt == "script":
162165 rule.Trigger.ResourceType = []string{ResourceTypeScript}
166+ hasExplicitResourceType = true
163167 case opt == "image":
164168 rule.Trigger.ResourceType = []string{ResourceTypeImage}
169+ hasExplicitResourceType = true
165170 case opt == "stylesheet":
166171 rule.Trigger.ResourceType = []string{ResourceTypeStyleSheet}
172+ hasExplicitResourceType = true
167173 case opt == "font":
168174 rule.Trigger.ResourceType = []string{ResourceTypeFont}
175+ hasExplicitResourceType = true
169176 case opt == "media":
170177 rule.Trigger.ResourceType = []string{ResourceTypeMedia}
178+ hasExplicitResourceType = true
171179 case opt == "document":
172180 rule.Trigger.ResourceType = []string{ResourceTypeDocument}
181+ hasExplicitResourceType = true
173182 case opt == "popup":
174183 rule.Trigger.ResourceType = []string{ResourceTypePopup}
184+ hasExplicitResourceType = true
185+ }
186+ }
187+
188+ // CRITICAL: If no resource type specified, default to safe types that exclude "document".
189+ // WebKit blocks ALL resource types (including main document) when resource-type is omitted.
190+ // This prevents blocking the main page when navigating to a URL matched by ad rules.
191+ if !hasExplicitResourceType && !isException {
192+ rule.Trigger.ResourceType = []string{
193+ ResourceTypeScript,
194+ ResourceTypeImage,
195+ ResourceTypeStyleSheet,
196+ ResourceTypeFont,
197+ ResourceTypeMedia,
198+ ResourceTypeSVGDocument,
175199 }
176200 }
177201
@@ -132,6 +132,9 @@ func (fc *FilterConverter) parseNetworkFilter(line string) error {
132 },132 },
133 }133 }
134 134
135+ // Track if resource type was explicitly set
136+ hasExplicitResourceType := false
137+
135 // Parse options138 // Parse options
136 for _, opt := range options {139 for _, opt := range options {
137 opt = strings.TrimSpace(opt)140 opt = strings.TrimSpace(opt)
@@ -160,18 +163,39 @@ func (fc *FilterConverter) parseNetworkFilter(line string) error {
160 }163 }
161 case opt == "script":164 case opt == "script":
162 rule.Trigger.ResourceType = []string{ResourceTypeScript}165 rule.Trigger.ResourceType = []string{ResourceTypeScript}
166+ hasExplicitResourceType = true
163 case opt == "image":167 case opt == "image":
164 rule.Trigger.ResourceType = []string{ResourceTypeImage}168 rule.Trigger.ResourceType = []string{ResourceTypeImage}
169+ hasExplicitResourceType = true
165 case opt == "stylesheet":170 case opt == "stylesheet":
166 rule.Trigger.ResourceType = []string{ResourceTypeStyleSheet}171 rule.Trigger.ResourceType = []string{ResourceTypeStyleSheet}
172+ hasExplicitResourceType = true
167 case opt == "font":173 case opt == "font":
168 rule.Trigger.ResourceType = []string{ResourceTypeFont}174 rule.Trigger.ResourceType = []string{ResourceTypeFont}
175+ hasExplicitResourceType = true
169 case opt == "media":176 case opt == "media":
170 rule.Trigger.ResourceType = []string{ResourceTypeMedia}177 rule.Trigger.ResourceType = []string{ResourceTypeMedia}
178+ hasExplicitResourceType = true
171 case opt == "document":179 case opt == "document":
172 rule.Trigger.ResourceType = []string{ResourceTypeDocument}180 rule.Trigger.ResourceType = []string{ResourceTypeDocument}
181+ hasExplicitResourceType = true
173 case opt == "popup":182 case opt == "popup":
174 rule.Trigger.ResourceType = []string{ResourceTypePopup}183 rule.Trigger.ResourceType = []string{ResourceTypePopup}
184+ hasExplicitResourceType = true
185+ }
186+ }
187+
188+ // CRITICAL: If no resource type specified, default to safe types that exclude "document".
189+ // WebKit blocks ALL resource types (including main document) when resource-type is omitted.
190+ // This prevents blocking the main page when navigating to a URL matched by ad rules.
191+ if !hasExplicitResourceType && !isException {
192+ rule.Trigger.ResourceType = []string{
193+ ResourceTypeScript,
194+ ResourceTypeImage,
195+ ResourceTypeStyleSheet,
196+ ResourceTypeFont,
197+ ResourceTypeMedia,
198+ ResourceTypeSVGDocument,
175 }199 }
176 }200 }
177 201
M internal/filtering/cosmetic/injector.go +27 -1
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "fmt"
77 "strings"
8+ "sync"
89
910 "github.com/bnema/dumber/internal/logging"
1011 )
@@ -14,6 +15,7 @@ var cosmeticScript string
1415
1516 // CosmeticInjector manages cosmetic filter injection
1617 type CosmeticInjector struct {
18+ mu sync.RWMutex
1719 domainRules map[string][]string
1820 genericRules []string
1921 scriptEnabled bool
@@ -36,6 +38,9 @@ func NewCosmeticInjector() *CosmeticInjector {
3638
3739 // InjectRules stores cosmetic rules for domain-specific filtering
3840 func (ci *CosmeticInjector) InjectRules(rules map[string][]string) error {
41+ ci.mu.Lock()
42+ defer ci.mu.Unlock()
43+
3944 if !ci.scriptEnabled {
4045 return fmt.Errorf("cosmetic filtering disabled")
4146 }
@@ -63,6 +68,9 @@ func (ci *CosmeticInjector) InjectRules(rules map[string][]string) error {
6368
6469 // GetScriptForDomain returns the complete cosmetic filtering script for a domain
6570 func (ci *CosmeticInjector) GetScriptForDomain(domain string) string {
71+ ci.mu.RLock()
72+ defer ci.mu.RUnlock()
73+
6674 if !ci.scriptEnabled {
6775 return ""
6876 }
@@ -130,6 +138,9 @@ func (ci *CosmeticInjector) GetScriptForDomain(domain string) string {
130138
131139 // UpdateRulesForDomain adds new rules for a specific domain
132140 func (ci *CosmeticInjector) UpdateRulesForDomain(domain string, newSelectors []string) {
141+ ci.mu.Lock()
142+ defer ci.mu.Unlock()
143+
133144 if !ci.scriptEnabled {
134145 return
135146 }
@@ -154,7 +165,11 @@ func (ci *CosmeticInjector) UpdateRulesForDomain(domain string, newSelectors []s
154165
155166 // GetUpdateScript returns JavaScript to update rules dynamically
156167 func (ci *CosmeticInjector) GetUpdateScript(newSelectors []string) string {
157- if !ci.scriptEnabled || len(newSelectors) == 0 {
168+ ci.mu.RLock()
169+ enabled := ci.scriptEnabled
170+ ci.mu.RUnlock()
171+
172+ if !enabled || len(newSelectors) == 0 {
158173 return ""
159174 }
160175
@@ -190,6 +205,9 @@ func (ci *CosmeticInjector) GetCleanupScript() string {
190205
191206 // GetRulesForDomain returns all cosmetic rules that apply to a domain
192207 func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string {
208+ ci.mu.RLock()
209+ defer ci.mu.RUnlock()
210+
193211 var rules []string
194212
195213 // Add generic rules
@@ -215,7 +233,10 @@ func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string {
215233
216234 // Enable enables or disables cosmetic filtering
217235 func (ci *CosmeticInjector) Enable(enabled bool) {
236+ ci.mu.Lock()
218237 ci.scriptEnabled = enabled
238+ ci.mu.Unlock()
239+
219240 if enabled {
220241 logging.Info("Cosmetic filtering enabled")
221242 } else {
@@ -225,11 +246,16 @@ func (ci *CosmeticInjector) Enable(enabled bool) {
225246
226247 // IsEnabled returns whether cosmetic filtering is enabled
227248 func (ci *CosmeticInjector) IsEnabled() bool {
249+ ci.mu.RLock()
250+ defer ci.mu.RUnlock()
228251 return ci.scriptEnabled
229252 }
230253
231254 // GetStats returns statistics about loaded cosmetic rules
232255 func (ci *CosmeticInjector) GetStats() map[string]interface{} {
256+ ci.mu.RLock()
257+ defer ci.mu.RUnlock()
258+
233259 domainCount := len(ci.domainRules)
234260 totalRules := len(ci.genericRules)
235261
@@ -5,6 +5,7 @@ import (
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7 "strings"7 "strings"
8+ "sync"
8 9
9 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
10 )11 )
@@ -14,6 +15,7 @@ var cosmeticScript string
14 15
15 // CosmeticInjector manages cosmetic filter injection16 // CosmeticInjector manages cosmetic filter injection
16 type CosmeticInjector struct {17 type CosmeticInjector struct {
18+ mu sync.RWMutex
17 domainRules map[string][]string19 domainRules map[string][]string
18 genericRules []string20 genericRules []string
19 scriptEnabled bool21 scriptEnabled bool
@@ -36,6 +38,9 @@ func NewCosmeticInjector() *CosmeticInjector {
36 38
37 // InjectRules stores cosmetic rules for domain-specific filtering39 // InjectRules stores cosmetic rules for domain-specific filtering
38 func (ci *CosmeticInjector) InjectRules(rules map[string][]string) error {40 func (ci *CosmeticInjector) InjectRules(rules map[string][]string) error {
41+ ci.mu.Lock()
42+ defer ci.mu.Unlock()
43+
39 if !ci.scriptEnabled {44 if !ci.scriptEnabled {
40 return fmt.Errorf("cosmetic filtering disabled")45 return fmt.Errorf("cosmetic filtering disabled")
41 }46 }
@@ -63,6 +68,9 @@ func (ci *CosmeticInjector) InjectRules(rules map[string][]string) error {
63 68
64 // GetScriptForDomain returns the complete cosmetic filtering script for a domain69 // GetScriptForDomain returns the complete cosmetic filtering script for a domain
65 func (ci *CosmeticInjector) GetScriptForDomain(domain string) string {70 func (ci *CosmeticInjector) GetScriptForDomain(domain string) string {
71+ ci.mu.RLock()
72+ defer ci.mu.RUnlock()
73+
66 if !ci.scriptEnabled {74 if !ci.scriptEnabled {
67 return ""75 return ""
68 }76 }
@@ -130,6 +138,9 @@ func (ci *CosmeticInjector) GetScriptForDomain(domain string) string {
130 138
131 // UpdateRulesForDomain adds new rules for a specific domain139 // UpdateRulesForDomain adds new rules for a specific domain
132 func (ci *CosmeticInjector) UpdateRulesForDomain(domain string, newSelectors []string) {140 func (ci *CosmeticInjector) UpdateRulesForDomain(domain string, newSelectors []string) {
141+ ci.mu.Lock()
142+ defer ci.mu.Unlock()
143+
133 if !ci.scriptEnabled {144 if !ci.scriptEnabled {
134 return145 return
135 }146 }
@@ -154,7 +165,11 @@ func (ci *CosmeticInjector) UpdateRulesForDomain(domain string, newSelectors []s
154 165
155 // GetUpdateScript returns JavaScript to update rules dynamically166 // GetUpdateScript returns JavaScript to update rules dynamically
156 func (ci *CosmeticInjector) GetUpdateScript(newSelectors []string) string {167 func (ci *CosmeticInjector) GetUpdateScript(newSelectors []string) string {
157- if !ci.scriptEnabled || len(newSelectors) == 0 {168+ ci.mu.RLock()
169+ enabled := ci.scriptEnabled
170+ ci.mu.RUnlock()
171+
172+ if !enabled || len(newSelectors) == 0 {
158 return ""173 return ""
159 }174 }
160 175
@@ -190,6 +205,9 @@ func (ci *CosmeticInjector) GetCleanupScript() string {
190 205
191 // GetRulesForDomain returns all cosmetic rules that apply to a domain206 // GetRulesForDomain returns all cosmetic rules that apply to a domain
192 func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string {207 func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string {
208+ ci.mu.RLock()
209+ defer ci.mu.RUnlock()
210+
193 var rules []string211 var rules []string
194 212
195 // Add generic rules213 // Add generic rules
@@ -215,7 +233,10 @@ func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string {
215 233
216 // Enable enables or disables cosmetic filtering234 // Enable enables or disables cosmetic filtering
217 func (ci *CosmeticInjector) Enable(enabled bool) {235 func (ci *CosmeticInjector) Enable(enabled bool) {
236+ ci.mu.Lock()
218 ci.scriptEnabled = enabled237 ci.scriptEnabled = enabled
238+ ci.mu.Unlock()
239+
219 if enabled {240 if enabled {
220 logging.Info("Cosmetic filtering enabled")241 logging.Info("Cosmetic filtering enabled")
221 } else {242 } else {
@@ -225,11 +246,16 @@ func (ci *CosmeticInjector) Enable(enabled bool) {
225 246
226 // IsEnabled returns whether cosmetic filtering is enabled247 // IsEnabled returns whether cosmetic filtering is enabled
227 func (ci *CosmeticInjector) IsEnabled() bool {248 func (ci *CosmeticInjector) IsEnabled() bool {
249+ ci.mu.RLock()
250+ defer ci.mu.RUnlock()
228 return ci.scriptEnabled251 return ci.scriptEnabled
229 }252 }
230 253
231 // GetStats returns statistics about loaded cosmetic rules254 // GetStats returns statistics about loaded cosmetic rules
232 func (ci *CosmeticInjector) GetStats() map[string]interface{} {255 func (ci *CosmeticInjector) GetStats() map[string]interface{} {
256+ ci.mu.RLock()
257+ defer ci.mu.RUnlock()
258+
233 domainCount := len(ci.domainRules)259 domainCount := len(ci.domainRules)
234 totalRules := len(ci.genericRules)260 totalRules := len(ci.genericRules)
235 261
M internal/filtering/manager.go +5 -37
@@ -297,9 +297,8 @@ func (fm *FilterManager) compileFromSources(ctx context.Context) {
297297 if compiled != nil && len(compiled.NetworkRules) > 0 {
298298 hasContent = true
299299 merged.Merge(compiled)
300- // Apply incrementally for faster initial blocking
301- logging.Info(fmt.Sprintf("[filtering] Applying incremental filters: %d network rules", len(merged.NetworkRules)))
302- fm.applyFilters(merged)
300+ // Apply incrementally removed to prevent excessive recompilation/churn
301+ // logging.Info(fmt.Sprintf("[filtering] Partial merge: %d network rules", len(merged.NetworkRules)))
303302 }
304303 }
305304
@@ -307,6 +306,9 @@ func (fm *FilterManager) compileFromSources(ctx context.Context) {
307306 if hasContent {
308307 logging.Info(fmt.Sprintf("[filtering] Successfully compiled %d network rules from sources", len(merged.NetworkRules)))
309308
309+ // Apply the final merged filters
310+ fm.applyFilters(merged)
311+
310312 // Cache the final result
311313 if err := fm.store.SaveCache(merged); err != nil {
312314 logging.Error(fmt.Sprintf("Failed to save filter cache: %v", err))
@@ -412,40 +414,6 @@ func (fm *FilterManager) applyFilters(filters *CompiledFilters) {
412414 logging.Info(fmt.Sprintf("[filtering] Applied %d whitelist rules (ignore-previous-rules)", len(fm.cachedWhitelistRules)))
413415 }
414416
415- // Add uBlock Origin style scriptlets to handle broken page loading
416-
417- // Instead of blocking completely, add exception rules for critical ad scripts that break page loading
418- // This allows the page to load while neutralizing tracking
419- antiBreakageRules := []converter.WebKitRule{
420- {
421- Trigger: converter.Trigger{
422- URLFilter: "googlesyndication.com",
423- ResourceType: []string{converter.ResourceTypeScript},
424- },
425- Action: converter.Action{
426- Type: converter.ActionTypeBlock,
427- },
428- },
429- {
430- Trigger: converter.Trigger{
431- URLFilter: "doubleclick.net",
432- },
433- Action: converter.Action{
434- Type: converter.ActionTypeBlock,
435- },
436- },
437- {
438- Trigger: converter.Trigger{
439- URLFilter: "googletagservices.com/tag/js/gpt.js",
440- ResourceType: []string{converter.ResourceTypeScript},
441- },
442- Action: converter.Action{
443- Type: converter.ActionTypeBlock,
444- },
445- },
446- }
447- filters.NetworkRules = append(filters.NetworkRules, antiBreakageRules...)
448-
449417 // Add internal scheme exceptions LAST to ensure they override all blocking rules
450418 // The dumb:// scheme is used for internal pages (homepage, blocked page, etc.)
451419 // and must never be blocked by content filtering
@@ -297,9 +297,8 @@ func (fm *FilterManager) compileFromSources(ctx context.Context) {
297 if compiled != nil && len(compiled.NetworkRules) > 0 {297 if compiled != nil && len(compiled.NetworkRules) > 0 {
298 hasContent = true298 hasContent = true
299 merged.Merge(compiled)299 merged.Merge(compiled)
300- // Apply incrementally for faster initial blocking300+ // Apply incrementally removed to prevent excessive recompilation/churn
301- logging.Info(fmt.Sprintf("[filtering] Applying incremental filters: %d network rules", len(merged.NetworkRules)))301+ // logging.Info(fmt.Sprintf("[filtering] Partial merge: %d network rules", len(merged.NetworkRules)))
302- fm.applyFilters(merged)
303 }302 }
304 }303 }
305 304
@@ -307,6 +306,9 @@ func (fm *FilterManager) compileFromSources(ctx context.Context) {
307 if hasContent {306 if hasContent {
308 logging.Info(fmt.Sprintf("[filtering] Successfully compiled %d network rules from sources", len(merged.NetworkRules)))307 logging.Info(fmt.Sprintf("[filtering] Successfully compiled %d network rules from sources", len(merged.NetworkRules)))
309 308
309+ // Apply the final merged filters
310+ fm.applyFilters(merged)
311+
310 // Cache the final result312 // Cache the final result
311 if err := fm.store.SaveCache(merged); err != nil {313 if err := fm.store.SaveCache(merged); err != nil {
312 logging.Error(fmt.Sprintf("Failed to save filter cache: %v", err))314 logging.Error(fmt.Sprintf("Failed to save filter cache: %v", err))
@@ -412,40 +414,6 @@ func (fm *FilterManager) applyFilters(filters *CompiledFilters) {
412 logging.Info(fmt.Sprintf("[filtering] Applied %d whitelist rules (ignore-previous-rules)", len(fm.cachedWhitelistRules)))414 logging.Info(fmt.Sprintf("[filtering] Applied %d whitelist rules (ignore-previous-rules)", len(fm.cachedWhitelistRules)))
413 }415 }
414 416
415- // Add uBlock Origin style scriptlets to handle broken page loading
416-
417- // Instead of blocking completely, add exception rules for critical ad scripts that break page loading
418- // This allows the page to load while neutralizing tracking
419- antiBreakageRules := []converter.WebKitRule{
420- {
421- Trigger: converter.Trigger{
422- URLFilter: "googlesyndication.com",
423- ResourceType: []string{converter.ResourceTypeScript},
424- },
425- Action: converter.Action{
426- Type: converter.ActionTypeBlock,
427- },
428- },
429- {
430- Trigger: converter.Trigger{
431- URLFilter: "doubleclick.net",
432- },
433- Action: converter.Action{
434- Type: converter.ActionTypeBlock,
435- },
436- },
437- {
438- Trigger: converter.Trigger{
439- URLFilter: "googletagservices.com/tag/js/gpt.js",
440- ResourceType: []string{converter.ResourceTypeScript},
441- },
442- Action: converter.Action{
443- Type: converter.ActionTypeBlock,
444- },
445- },
446- }
447- filters.NetworkRules = append(filters.NetworkRules, antiBreakageRules...)
448-
449 // Add internal scheme exceptions LAST to ensure they override all blocking rules417 // Add internal scheme exceptions LAST to ensure they override all blocking rules
450 // The dumb:// scheme is used for internal pages (homepage, blocked page, etc.)418 // The dumb:// scheme is used for internal pages (homepage, blocked page, etc.)
451 // and must never be blocked by content filtering419 // and must never be blocked by content filtering
M internal/filtering/service.go +96 -277
@@ -4,87 +4,33 @@ package filtering
44 import (
55 "fmt"
66 "net/url"
7+ "strings"
78 "sync"
89
910 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
10- glib "github.com/diamondburned/gotk4/pkg/glib/v2"
1111
1212 "github.com/bnema/dumber/internal/filtering/cosmetic"
1313 "github.com/bnema/dumber/internal/logging"
1414 pkgwebkit "github.com/bnema/dumber/pkg/webkit"
1515 )
1616
17-// runWhenWidgetReady waits until the WebView exposes a valid UserContentManager before invoking fn on the GTK main thread.
18-// This avoids racing freshly split panes whose underlying widgets aren't fully initialized yet.
19-func (s *ContentBlockingService) runWhenWidgetReady(wv *pkgwebkit.WebView, fn func()) {
20- if wv == nil || wv.IsDestroyed() || fn == nil {
21- return
22- }
23-
24- const maxAttempts = 50 // up to ~1s total
25- const retryDelayMS = uint(20) // 20ms between checks
26-
27- var tryApply func(attempt int) bool
28- tryApply = func(attempt int) bool {
29- if wv.IsDestroyed() {
30- return false
31- }
32-
33- gtkView := wv.GetWebView()
34- if gtkView == nil {
35- return attempt < maxAttempts
36- }
37-
38- if ucm := gtkView.UserContentManager(); ucm == nil {
39- return attempt < maxAttempts
40- }
41-
42- pkgwebkit.RunOnMainThread(func() {
43- if wv.IsDestroyed() {
44- return
45- }
46- fn()
47- })
48- return false
49- }
50-
51- attempts := 0
52- if !tryApply(attempts) {
53- return
54- }
55-
56- glib.TimeoutAdd(retryDelayMS, func() bool {
57- attempts++
58- return tryApply(attempts)
59- })
60-}
17+// localSchemes are URI schemes that don't need content filtering.
18+var localSchemes = []string{"about:", "dumb://", "file://", "data:"}
6119
6220 // ContentBlockingService manages content blocking for all WebViews.
63-// It ensures network filters and cosmetic rules are applied consistently
64-// across all WebViews in the application.
21+// Simplified architecture:
22+// - Compile filter once at startup
23+// - Apply to each WebView's UCM directly (AddFilter is idempotent)
24+// - Inject cosmetic rules on navigation
6525 type ContentBlockingService struct {
6626 mu sync.RWMutex
6727
68- // Filter data
69- networkFilterJSON []byte
70- filterManager *FilterManager
71- cosmeticInjector *cosmetic.CosmeticInjector
72- filtersReady bool
73-
74- // WebView registry - tracks all active WebViews
75- webViews map[*pkgwebkit.WebView]webViewState
76-
77- // Content blocking manager for compiling filters
28+ filterManager *FilterManager
29+ cosmeticInjector *cosmetic.CosmeticInjector
7830 contentBlockingMgr *pkgwebkit.ContentBlockingManager
79-
80- // Data directory for filter storage
81- dataDir string
82-}
83-
84-// webViewState tracks per-WebView filter application state
85-type webViewState struct {
86- networkApplied bool
87- cosmeticApplied bool
31+ networkFilterJSON []byte
32+ filtersReady bool
33+ dataDir string
8834 }
8935
9036 // NewContentBlockingService creates a new content blocking service.
@@ -98,210 +44,114 @@ func NewContentBlockingService(dataDir string, filterManager *FilterManager) (*C
9844 return nil, fmt.Errorf("failed to create content blocking manager: %w", err)
9945 }
10046
101- service := &ContentBlockingService{
47+ return &ContentBlockingService{
10248 filterManager: filterManager,
10349 cosmeticInjector: filterManager.cosmeticInjector,
104- webViews: make(map[*pkgwebkit.WebView]webViewState),
10550 contentBlockingMgr: cbm,
10651 dataDir: dataDir,
107- }
108-
109- return service, nil
52+ }, nil
11053 }
11154
11255 // SetFiltersReady is called when filters have been parsed and are ready to compile.
113-// It compiles filters asynchronously, then applies to all registered WebViews.
56+// Compiles filters asynchronously - callback runs on GTK main thread.
11457 func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) {
11558 s.mu.Lock()
11659 s.networkFilterJSON = networkJSON
117- s.filtersReady = true // Cosmetic rules are ready immediately
118- webViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
119- for wv := range s.webViews {
120- webViews = append(webViews, wv)
121- }
60+ s.filtersReady = true
12261 s.mu.Unlock()
12362
124- // Inject cosmetic scripts IMMEDIATELY (don't wait for network filter compilation)
125- // This hides ads visually within milliseconds while network filters compile in background
126- for _, wv := range webViews {
127- webView := wv // capture for closure
128- s.runWhenWidgetReady(webView, func() {
129- s.injectCosmeticBaseScript(webView)
130- if uri := webView.GetCurrentURL(); uri != "" {
131- s.injectCosmeticRules(webView, uri)
132- }
133- })
134- }
135- logging.Info("[filtering] Cosmetic rules injected immediately to all WebViews")
136-
13763 if len(networkJSON) == 0 {
13864 logging.Warn("[filtering] SetFiltersReady called with empty network filter JSON")
13965 return
14066 }
14167
142- // Compile network filters asynchronously - this happens in the background
143- // and does not block the main thread (takes ~6 seconds)
68+ // Compile network filters asynchronously
69+ // The callback runs on GTK main thread (GIO async pattern)
14470 s.contentBlockingMgr.CompileFilters("adblock-filters", networkJSON, func(err error) {
14571 if err != nil {
14672 logging.Error(fmt.Sprintf("[filtering] Filter compilation failed: %v", err))
14773 return
14874 }
149-
150- logging.Info("[filtering] Network filter compilation complete, applying to WebViews")
151-
152- // Get current WebViews (may have changed since cosmetic injection)
153- s.mu.RLock()
154- currentWebViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
155- for wv := range s.webViews {
156- currentWebViews = append(currentWebViews, wv)
157- }
158- s.mu.RUnlock()
159-
160- // Apply network filters to all registered WebViews
161- for _, wv := range currentWebViews {
162- webView := wv // capture for closure
163- s.runWhenWidgetReady(webView, func() {
164- s.applyFiltersToWebView(webView)
165- })
166- }
75+ logging.Info("[filtering] Network filter compilation complete")
16776 })
16877 }
16978
17079 // RegisterWebView registers a WebView for content blocking.
171-// If filters are already compiled, they will be applied immediately.
80+// If filters are compiled, applies them immediately.
81+// Sets up cosmetic filtering hooks for navigation.
17282 func (s *ContentBlockingService) RegisterWebView(wv *pkgwebkit.WebView) {
17383 if wv == nil {
17484 return
17585 }
17686
177- s.mu.Lock()
178- if _, exists := s.webViews[wv]; exists {
179- s.mu.Unlock()
180- return // Already registered
181- }
182- s.webViews[wv] = webViewState{}
183- filtersReady := s.filtersReady
184- s.mu.Unlock()
185-
18687 logging.Debug(fmt.Sprintf("[filtering] Registered WebView %d for content blocking", wv.ID()))
18788
188- // Setup cosmetic filtering hooks for domain-specific rules (navigation handler)
189- s.setupCosmeticHooks(wv)
89+ // Setup cosmetic filtering hook for navigation
90+ s.setupCosmeticHook(wv)
91+
92+ // Inject cosmetic base script if filters are ready
93+ s.mu.RLock()
94+ filtersReady := s.filtersReady
95+ s.mu.RUnlock()
19096
191- // Only inject cosmetic base script if filters are loaded (rules available)
192- // Otherwise, it will be injected when SetFiltersReady completes
19397 if filtersReady {
19498 s.injectCosmeticBaseScript(wv)
19599 }
196100
197- // If network filters are already compiled, apply them immediately (non-blocking)
101+ // Apply network filter if already compiled (idempotent - safe to call multiple times)
198102 if s.contentBlockingMgr.IsFilterCompiled() {
199- webView := wv // capture for closure
200- s.runWhenWidgetReady(webView, func() {
201- s.applyFiltersToWebView(webView)
202- })
103+ s.applyNetworkFilter(wv)
203104 }
204105 }
205106
206-// injectCosmeticBaseScript injects the base cosmetic filtering script into a WebView.
207-// This script sets up the cosmetic filtering infrastructure (MutationObserver, hide functions, etc.)
208-func (s *ContentBlockingService) injectCosmeticBaseScript(wv *pkgwebkit.WebView) {
209- if wv == nil || wv.IsDestroyed() {
210- return
211- }
212-
213- gtkView := wv.GetWebView()
214- if gtkView == nil {
215- return
216- }
217-
218- ucm := gtkView.UserContentManager()
219- if ucm == nil {
220- return
221- }
222-
223- s.mu.RLock()
224- injector := s.cosmeticInjector
225- s.mu.RUnlock()
226-
227- if injector == nil || !injector.IsEnabled() {
228- return
229- }
230-
231- // Get the base cosmetic script (the infrastructure without domain-specific rules)
232- // Pass empty domain to get just the base script
233- baseScript := injector.GetScriptForDomain("")
234- if baseScript == "" {
235- logging.Debug(fmt.Sprintf("[filtering] No cosmetic base script available for WebView %d", wv.ID()))
236- return
237- }
238-
239- logging.Debug(fmt.Sprintf("[filtering] Injecting cosmetic base script into WebView %d (%d bytes)", wv.ID(), len(baseScript)))
240-
241- // Inject at document-end when DOM is ready for querySelector/MutationObserver
242- userScript := webkit.NewUserScript(
243- baseScript,
244- webkit.UserContentInjectAllFrames,
245- webkit.UserScriptInjectAtDocumentEnd,
246- nil, // allowList
247- nil, // blockList
248- )
249-
250- ucm.AddScript(userScript)
251- logging.Debug(fmt.Sprintf("[filtering] Injected cosmetic base script into WebView %d", wv.ID()))
107+// UnregisterWebView is a no-op in simplified architecture.
108+// WebKit manages filter lifetime via UCM.
109+func (s *ContentBlockingService) UnregisterWebView(wv *pkgwebkit.WebView) {
110+ // No-op: UCM handles filter cleanup when WebView is destroyed
252111 }
253112
254-// UnregisterWebView removes a WebView from the registry.
255-func (s *ContentBlockingService) UnregisterWebView(wv *pkgwebkit.WebView) {
113+// setupCosmeticHook sets up navigation hook for cosmetic filtering.
114+func (s *ContentBlockingService) setupCosmeticHook(wv *pkgwebkit.WebView) {
256115 if wv == nil {
257116 return
258117 }
259118
260- s.mu.Lock()
261- delete(s.webViews, wv)
262- s.mu.Unlock()
119+ // Load committed handler runs on GTK main thread
120+ wv.RegisterLoadCommittedHandler(func(uri string) {
121+ if wv.IsDestroyed() {
122+ return
123+ }
124+ s.injectCosmeticRules(wv, uri)
125+ })
263126 }
264127
265-// applyFiltersToWebView applies both network and cosmetic filters to a WebView.
266-func (s *ContentBlockingService) applyFiltersToWebView(wv *pkgwebkit.WebView) {
128+// applyNetworkFilter applies the compiled network filter to a WebView.
129+// AddFilter is idempotent - safe to call multiple times.
130+func (s *ContentBlockingService) applyNetworkFilter(wv *pkgwebkit.WebView) {
267131 if wv == nil || wv.IsDestroyed() {
268132 return
269133 }
270134
271- // Avoid triggering compilation on the GTK thread; only apply once compiled.
272- if !s.contentBlockingMgr.IsFilterCompiled() {
135+ gtkView := wv.GetWebView()
136+ if gtkView == nil {
273137 return
274138 }
275139
276- s.mu.Lock()
277- state := s.webViews[wv]
278- if state.networkApplied {
279- s.mu.Unlock()
140+ ucm := gtkView.UserContentManager()
141+ if ucm == nil {
280142 return
281143 }
282- // Mark as applying to avoid double AddFilter races
283- state.networkApplied = true
284- s.webViews[wv] = state
285- networkJSON := s.networkFilterJSON
286- s.mu.Unlock()
287144
288- if len(networkJSON) == 0 {
289- // Nothing to apply; reset flag
290- s.mu.Lock()
291- state.networkApplied = false
292- s.webViews[wv] = state
293- s.mu.Unlock()
145+ if err := s.contentBlockingMgr.ApplyCompiledFilter(ucm); err != nil {
146+ logging.Error(fmt.Sprintf("[filtering] Failed to apply filter to WebView %d: %v", wv.ID(), err))
294147 return
295148 }
296149
297- webView := wv // capture for closure
298- s.runWhenWidgetReady(webView, func() {
299- s.applyNetworkFilters(webView, networkJSON)
300- })
150+ logging.Debug(fmt.Sprintf("[filtering] Applied network filters to WebView %d", wv.ID()))
301151 }
302152
303-// applyNetworkFilters applies network blocking rules to a WebView.
304-func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filterJSON []byte) {
153+// injectCosmeticBaseScript injects the base cosmetic filtering script.
154+func (s *ContentBlockingService) injectCosmeticBaseScript(wv *pkgwebkit.WebView) {
305155 if wv == nil || wv.IsDestroyed() {
306156 return
307157 }
@@ -313,64 +163,55 @@ func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filt
313163
314164 ucm := gtkView.UserContentManager()
315165 if ucm == nil {
316- // Reset state so a future ready WebView can retry
317- s.mu.Lock()
318- if state, exists := s.webViews[wv]; exists {
319- state.networkApplied = false
320- s.webViews[wv] = state
321- }
322- s.mu.Unlock()
323166 return
324167 }
325168
326- // Use shared identifier so all WebViews reuse the same compiled filter
327- const identifier = "adblock-filters"
328-
329- // Try to apply cached compiled filter first (instant)
330- if s.contentBlockingMgr.IsFilterCompiled() {
331- if err := s.contentBlockingMgr.ApplyCompiledFilter(ucm); err != nil {
332- logging.Error(fmt.Sprintf("[filtering] Failed to apply cached filter to WebView %d: %v", wv.ID(), err))
333- return
334- }
335- } else if len(filterJSON) > 0 {
336- // Fallback: compile if not yet compiled (first WebView)
337- err := s.contentBlockingMgr.ApplyFiltersFromJSON(ucm, identifier, filterJSON)
338- if err != nil {
339- logging.Error(fmt.Sprintf("[filtering] Failed to apply network filters to WebView %d: %v", wv.ID(), err))
340- return
341- }
342- } else {
169+ injector := s.cosmeticInjector
170+ if injector == nil || !injector.IsEnabled() {
343171 return
344172 }
345173
346- logging.Debug(fmt.Sprintf("[filtering] Applied network filters to WebView %d", wv.ID()))
347-}
348-
349-// setupCosmeticHooks sets up hooks for cosmetic filtering on navigation events.
350-func (s *ContentBlockingService) setupCosmeticHooks(wv *pkgwebkit.WebView) {
351- if wv == nil {
174+ // Get base script (empty domain = infrastructure only)
175+ baseScript := injector.GetScriptForDomain("")
176+ if baseScript == "" {
352177 return
353178 }
354179
355- // Register load-committed handler to inject domain-specific cosmetic rules
356- webView := wv // capture for closure
357- wv.RegisterLoadCommittedHandler(func(uri string) {
358- if webView.IsDestroyed() {
359- return
180+ // Inject at document-end when DOM is ready
181+ userScript := webkit.NewUserScript(
182+ baseScript,
183+ webkit.UserContentInjectAllFrames,
184+ webkit.UserScriptInjectAtDocumentEnd,
185+ nil, nil,
186+ )
187+
188+ ucm.AddScript(userScript)
189+ logging.Debug(fmt.Sprintf("[filtering] Injected cosmetic base script into WebView %d", wv.ID()))
190+}
191+
192+// isLocalScheme returns true if the URI uses a local/internal scheme
193+// that doesn't need content filtering.
194+func isLocalScheme(uri string) bool {
195+ for _, scheme := range localSchemes {
196+ if strings.HasPrefix(uri, scheme) {
197+ return true
360198 }
361- s.runWhenWidgetReady(webView, func() {
362- s.injectCosmeticRules(webView, uri)
363- })
364- })
199+ }
200+ return false
365201 }
366202
367-// injectCosmeticRules injects cosmetic filtering rules for the current page.
203+// injectCosmeticRules injects domain-specific cosmetic rules.
204+// Called from load-committed handler (already on GTK main thread).
368205 func (s *ContentBlockingService) injectCosmeticRules(wv *pkgwebkit.WebView, uri string) {
369206 if wv == nil || wv.IsDestroyed() || uri == "" {
370207 return
371208 }
372209
373- // Parse domain from URI
210+ // Skip local/internal schemes
211+ if isLocalScheme(uri) {
212+ return
213+ }
214+
374215 parsedURL, err := url.Parse(uri)
375216 if err != nil || parsedURL.Host == "" {
376217 return
@@ -378,11 +219,7 @@ func (s *ContentBlockingService) injectCosmeticRules(wv *pkgwebkit.WebView, uri
378219
379220 domain := parsedURL.Hostname()
380221
381- // Get cosmetic script for this domain
382- s.mu.RLock()
383222 injector := s.cosmeticInjector
384- s.mu.RUnlock()
385-
386223 if injector == nil || !injector.IsEnabled() {
387224 return
388225 }
@@ -392,36 +229,27 @@ func (s *ContentBlockingService) injectCosmeticRules(wv *pkgwebkit.WebView, uri
392229 return
393230 }
394231
395- // Inject the script
396232 logging.Debug(fmt.Sprintf("[filtering] Injecting cosmetic rules for domain: %s (%d bytes)", domain, len(script)))
397- webView := wv // capture for closure
398- pkgwebkit.RunOnMainThread(func() {
399- if webView.IsDestroyed() {
400- return
401- }
402- if err := webView.InjectScript(script); err != nil {
403- logging.Error(fmt.Sprintf("[filtering] Failed to inject cosmetic rules for %s: %v", domain, err))
404- } else {
405- logging.Debug(fmt.Sprintf("[filtering] Successfully injected cosmetic rules for domain: %s", domain))
406- }
407- })
233+
234+ // Already on GTK main thread from load-committed handler
235+ if err := wv.InjectScript(script); err != nil {
236+ logging.Error(fmt.Sprintf("[filtering] Failed to inject cosmetic rules for %s: %v", domain, err))
237+ } else {
238+ logging.Debug(fmt.Sprintf("[filtering] Successfully injected cosmetic rules for domain: %s", domain))
239+ }
408240 }
409241
410242 // GetFilterManager returns the underlying filter manager.
411243 func (s *ContentBlockingService) GetFilterManager() *FilterManager {
412- s.mu.RLock()
413- defer s.mu.RUnlock()
414244 return s.filterManager
415245 }
416246
417247 // GetCosmeticInjector returns the cosmetic injector.
418248 func (s *ContentBlockingService) GetCosmeticInjector() *cosmetic.CosmeticInjector {
419- s.mu.RLock()
420- defer s.mu.RUnlock()
421249 return s.cosmeticInjector
422250 }
423251
424-// IsReady returns whether filters have been loaded and are ready to apply.
252+// IsReady returns whether filters have been loaded.
425253 func (s *ContentBlockingService) IsReady() bool {
426254 s.mu.RLock()
427255 defer s.mu.RUnlock()
@@ -433,18 +261,9 @@ func (s *ContentBlockingService) GetStats() map[string]interface{} {
433261 s.mu.RLock()
434262 defer s.mu.RUnlock()
435263
436- webViewCount := len(s.webViews)
437- appliedCount := 0
438- for _, state := range s.webViews {
439- if state.networkApplied {
440- appliedCount++
441- }
442- }
443-
444264 stats := map[string]interface{}{
445265 "ready": s.filtersReady,
446- "webview_count": webViewCount,
447- "filters_applied": appliedCount,
266+ "filter_compiled": s.contentBlockingMgr.IsFilterCompiled(),
448267 "network_rules_bytes": len(s.networkFilterJSON),
449268 }
450269
@@ -4,87 +4,33 @@ package filtering
4 import (4 import (
5 "fmt"5 "fmt"
6 "net/url"6 "net/url"
7+ "strings"
7 "sync"8 "sync"
8 9
9 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"10 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
10- glib "github.com/diamondburned/gotk4/pkg/glib/v2"
11 11
12 "github.com/bnema/dumber/internal/filtering/cosmetic"12 "github.com/bnema/dumber/internal/filtering/cosmetic"
13 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
14 pkgwebkit "github.com/bnema/dumber/pkg/webkit"14 pkgwebkit "github.com/bnema/dumber/pkg/webkit"
15 )15 )
16 16
17-// runWhenWidgetReady waits until the WebView exposes a valid UserContentManager before invoking fn on the GTK main thread.17+// localSchemes are URI schemes that don't need content filtering.
18-// This avoids racing freshly split panes whose underlying widgets aren't fully initialized yet.18+var localSchemes = []string{"about:", "dumb://", "file://", "data:"}
19-func (s *ContentBlockingService) runWhenWidgetReady(wv *pkgwebkit.WebView, fn func()) {
20- if wv == nil || wv.IsDestroyed() || fn == nil {
21- return
22- }
23-
24- const maxAttempts = 50 // up to ~1s total
25- const retryDelayMS = uint(20) // 20ms between checks
26-
27- var tryApply func(attempt int) bool
28- tryApply = func(attempt int) bool {
29- if wv.IsDestroyed() {
30- return false
31- }
32-
33- gtkView := wv.GetWebView()
34- if gtkView == nil {
35- return attempt < maxAttempts
36- }
37-
38- if ucm := gtkView.UserContentManager(); ucm == nil {
39- return attempt < maxAttempts
40- }
41-
42- pkgwebkit.RunOnMainThread(func() {
43- if wv.IsDestroyed() {
44- return
45- }
46- fn()
47- })
48- return false
49- }
50-
51- attempts := 0
52- if !tryApply(attempts) {
53- return
54- }
55-
56- glib.TimeoutAdd(retryDelayMS, func() bool {
57- attempts++
58- return tryApply(attempts)
59- })
60-}
61 19
62 // ContentBlockingService manages content blocking for all WebViews.20 // ContentBlockingService manages content blocking for all WebViews.
63-// It ensures network filters and cosmetic rules are applied consistently21+// Simplified architecture:
64-// across all WebViews in the application.22+// - Compile filter once at startup
23+// - Apply to each WebView's UCM directly (AddFilter is idempotent)
24+// - Inject cosmetic rules on navigation
65 type ContentBlockingService struct {25 type ContentBlockingService struct {
66 mu sync.RWMutex26 mu sync.RWMutex
67 27
68- // Filter data28+ filterManager *FilterManager
69- networkFilterJSON []byte29+ cosmeticInjector *cosmetic.CosmeticInjector
70- filterManager *FilterManager
71- cosmeticInjector *cosmetic.CosmeticInjector
72- filtersReady bool
73-
74- // WebView registry - tracks all active WebViews
75- webViews map[*pkgwebkit.WebView]webViewState
76-
77- // Content blocking manager for compiling filters
78 contentBlockingMgr *pkgwebkit.ContentBlockingManager30 contentBlockingMgr *pkgwebkit.ContentBlockingManager
79-31+ networkFilterJSON []byte
80- // Data directory for filter storage32+ filtersReady bool
81- dataDir string33+ dataDir string
82-}
83-
84-// webViewState tracks per-WebView filter application state
85-type webViewState struct {
86- networkApplied bool
87- cosmeticApplied bool
88 }34 }
89 35
90 // NewContentBlockingService creates a new content blocking service.36 // NewContentBlockingService creates a new content blocking service.
@@ -98,210 +44,114 @@ func NewContentBlockingService(dataDir string, filterManager *FilterManager) (*C
98 return nil, fmt.Errorf("failed to create content blocking manager: %w", err)44 return nil, fmt.Errorf("failed to create content blocking manager: %w", err)
99 }45 }
100 46
101- service := &ContentBlockingService{47+ return &ContentBlockingService{
102 filterManager: filterManager,48 filterManager: filterManager,
103 cosmeticInjector: filterManager.cosmeticInjector,49 cosmeticInjector: filterManager.cosmeticInjector,
104- webViews: make(map[*pkgwebkit.WebView]webViewState),
105 contentBlockingMgr: cbm,50 contentBlockingMgr: cbm,
106 dataDir: dataDir,51 dataDir: dataDir,
107- }52+ }, nil
108-
109- return service, nil
110 }53 }
111 54
112 // SetFiltersReady is called when filters have been parsed and are ready to compile.55 // SetFiltersReady is called when filters have been parsed and are ready to compile.
113-// It compiles filters asynchronously, then applies to all registered WebViews.56+// Compiles filters asynchronously - callback runs on GTK main thread.
114 func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) {57 func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) {
115 s.mu.Lock()58 s.mu.Lock()
116 s.networkFilterJSON = networkJSON59 s.networkFilterJSON = networkJSON
117- s.filtersReady = true // Cosmetic rules are ready immediately60+ s.filtersReady = true
118- webViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
119- for wv := range s.webViews {
120- webViews = append(webViews, wv)
121- }
122 s.mu.Unlock()61 s.mu.Unlock()
123 62
124- // Inject cosmetic scripts IMMEDIATELY (don't wait for network filter compilation)
125- // This hides ads visually within milliseconds while network filters compile in background
126- for _, wv := range webViews {
127- webView := wv // capture for closure
128- s.runWhenWidgetReady(webView, func() {
129- s.injectCosmeticBaseScript(webView)
130- if uri := webView.GetCurrentURL(); uri != "" {
131- s.injectCosmeticRules(webView, uri)
132- }
133- })
134- }
135- logging.Info("[filtering] Cosmetic rules injected immediately to all WebViews")
136-
137 if len(networkJSON) == 0 {63 if len(networkJSON) == 0 {
138 logging.Warn("[filtering] SetFiltersReady called with empty network filter JSON")64 logging.Warn("[filtering] SetFiltersReady called with empty network filter JSON")
139 return65 return
140 }66 }
141 67
142- // Compile network filters asynchronously - this happens in the background68+ // Compile network filters asynchronously
143- // and does not block the main thread (takes ~6 seconds)69+ // The callback runs on GTK main thread (GIO async pattern)
144 s.contentBlockingMgr.CompileFilters("adblock-filters", networkJSON, func(err error) {70 s.contentBlockingMgr.CompileFilters("adblock-filters", networkJSON, func(err error) {
145 if err != nil {71 if err != nil {
146 logging.Error(fmt.Sprintf("[filtering] Filter compilation failed: %v", err))72 logging.Error(fmt.Sprintf("[filtering] Filter compilation failed: %v", err))
147 return73 return
148 }74 }
149-75+ logging.Info("[filtering] Network filter compilation complete")
150- logging.Info("[filtering] Network filter compilation complete, applying to WebViews")
151-
152- // Get current WebViews (may have changed since cosmetic injection)
153- s.mu.RLock()
154- currentWebViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
155- for wv := range s.webViews {
156- currentWebViews = append(currentWebViews, wv)
157- }
158- s.mu.RUnlock()
159-
160- // Apply network filters to all registered WebViews
161- for _, wv := range currentWebViews {
162- webView := wv // capture for closure
163- s.runWhenWidgetReady(webView, func() {
164- s.applyFiltersToWebView(webView)
165- })
166- }
167 })76 })
168 }77 }
169 78
170 // RegisterWebView registers a WebView for content blocking.79 // RegisterWebView registers a WebView for content blocking.
171-// If filters are already compiled, they will be applied immediately.80+// If filters are compiled, applies them immediately.
81+// Sets up cosmetic filtering hooks for navigation.
172 func (s *ContentBlockingService) RegisterWebView(wv *pkgwebkit.WebView) {82 func (s *ContentBlockingService) RegisterWebView(wv *pkgwebkit.WebView) {
173 if wv == nil {83 if wv == nil {
174 return84 return
175 }85 }
176 86
177- s.mu.Lock()
178- if _, exists := s.webViews[wv]; exists {
179- s.mu.Unlock()
180- return // Already registered
181- }
182- s.webViews[wv] = webViewState{}
183- filtersReady := s.filtersReady
184- s.mu.Unlock()
185-
186 logging.Debug(fmt.Sprintf("[filtering] Registered WebView %d for content blocking", wv.ID()))87 logging.Debug(fmt.Sprintf("[filtering] Registered WebView %d for content blocking", wv.ID()))
187 88
188- // Setup cosmetic filtering hooks for domain-specific rules (navigation handler)89+ // Setup cosmetic filtering hook for navigation
189- s.setupCosmeticHooks(wv)90+ s.setupCosmeticHook(wv)
91+
92+ // Inject cosmetic base script if filters are ready
93+ s.mu.RLock()
94+ filtersReady := s.filtersReady
95+ s.mu.RUnlock()
190 96
191- // Only inject cosmetic base script if filters are loaded (rules available)
192- // Otherwise, it will be injected when SetFiltersReady completes
193 if filtersReady {97 if filtersReady {
194 s.injectCosmeticBaseScript(wv)98 s.injectCosmeticBaseScript(wv)
195 }99 }
196 100
197- // If network filters are already compiled, apply them immediately (non-blocking)101+ // Apply network filter if already compiled (idempotent - safe to call multiple times)
198 if s.contentBlockingMgr.IsFilterCompiled() {102 if s.contentBlockingMgr.IsFilterCompiled() {
199- webView := wv // capture for closure103+ s.applyNetworkFilter(wv)
200- s.runWhenWidgetReady(webView, func() {
201- s.applyFiltersToWebView(webView)
202- })
203 }104 }
204 }105 }
205 106
206-// injectCosmeticBaseScript injects the base cosmetic filtering script into a WebView.107+// UnregisterWebView is a no-op in simplified architecture.
207-// This script sets up the cosmetic filtering infrastructure (MutationObserver, hide functions, etc.)108+// WebKit manages filter lifetime via UCM.
208-func (s *ContentBlockingService) injectCosmeticBaseScript(wv *pkgwebkit.WebView) {109+func (s *ContentBlockingService) UnregisterWebView(wv *pkgwebkit.WebView) {
209- if wv == nil || wv.IsDestroyed() {110+ // No-op: UCM handles filter cleanup when WebView is destroyed
210- return
211- }
212-
213- gtkView := wv.GetWebView()
214- if gtkView == nil {
215- return
216- }
217-
218- ucm := gtkView.UserContentManager()
219- if ucm == nil {
220- return
221- }
222-
223- s.mu.RLock()
224- injector := s.cosmeticInjector
225- s.mu.RUnlock()
226-
227- if injector == nil || !injector.IsEnabled() {
228- return
229- }
230-
231- // Get the base cosmetic script (the infrastructure without domain-specific rules)
232- // Pass empty domain to get just the base script
233- baseScript := injector.GetScriptForDomain("")
234- if baseScript == "" {
235- logging.Debug(fmt.Sprintf("[filtering] No cosmetic base script available for WebView %d", wv.ID()))
236- return
237- }
238-
239- logging.Debug(fmt.Sprintf("[filtering] Injecting cosmetic base script into WebView %d (%d bytes)", wv.ID(), len(baseScript)))
240-
241- // Inject at document-end when DOM is ready for querySelector/MutationObserver
242- userScript := webkit.NewUserScript(
243- baseScript,
244- webkit.UserContentInjectAllFrames,
245- webkit.UserScriptInjectAtDocumentEnd,
246- nil, // allowList
247- nil, // blockList
248- )
249-
250- ucm.AddScript(userScript)
251- logging.Debug(fmt.Sprintf("[filtering] Injected cosmetic base script into WebView %d", wv.ID()))
252 }111 }
253 112
254-// UnregisterWebView removes a WebView from the registry.113+// setupCosmeticHook sets up navigation hook for cosmetic filtering.
255-func (s *ContentBlockingService) UnregisterWebView(wv *pkgwebkit.WebView) {114+func (s *ContentBlockingService) setupCosmeticHook(wv *pkgwebkit.WebView) {
256 if wv == nil {115 if wv == nil {
257 return116 return
258 }117 }
259 118
260- s.mu.Lock()119+ // Load committed handler runs on GTK main thread
261- delete(s.webViews, wv)120+ wv.RegisterLoadCommittedHandler(func(uri string) {
262- s.mu.Unlock()121+ if wv.IsDestroyed() {
122+ return
123+ }
124+ s.injectCosmeticRules(wv, uri)
125+ })
263 }126 }
264 127
265-// applyFiltersToWebView applies both network and cosmetic filters to a WebView.128+// applyNetworkFilter applies the compiled network filter to a WebView.
266-func (s *ContentBlockingService) applyFiltersToWebView(wv *pkgwebkit.WebView) {129+// AddFilter is idempotent - safe to call multiple times.
130+func (s *ContentBlockingService) applyNetworkFilter(wv *pkgwebkit.WebView) {
267 if wv == nil || wv.IsDestroyed() {131 if wv == nil || wv.IsDestroyed() {
268 return132 return
269 }133 }
270 134
271- // Avoid triggering compilation on the GTK thread; only apply once compiled.135+ gtkView := wv.GetWebView()
272- if !s.contentBlockingMgr.IsFilterCompiled() {136+ if gtkView == nil {
273 return137 return
274 }138 }
275 139
276- s.mu.Lock()140+ ucm := gtkView.UserContentManager()
277- state := s.webViews[wv]141+ if ucm == nil {
278- if state.networkApplied {
279- s.mu.Unlock()
280 return142 return
281 }143 }
282- // Mark as applying to avoid double AddFilter races
283- state.networkApplied = true
284- s.webViews[wv] = state
285- networkJSON := s.networkFilterJSON
286- s.mu.Unlock()
287 144
288- if len(networkJSON) == 0 {145+ if err := s.contentBlockingMgr.ApplyCompiledFilter(ucm); err != nil {
289- // Nothing to apply; reset flag146+ logging.Error(fmt.Sprintf("[filtering] Failed to apply filter to WebView %d: %v", wv.ID(), err))
290- s.mu.Lock()
291- state.networkApplied = false
292- s.webViews[wv] = state
293- s.mu.Unlock()
294 return147 return
295 }148 }
296 149
297- webView := wv // capture for closure150+ logging.Debug(fmt.Sprintf("[filtering] Applied network filters to WebView %d", wv.ID()))
298- s.runWhenWidgetReady(webView, func() {
299- s.applyNetworkFilters(webView, networkJSON)
300- })
301 }151 }
302 152
303-// applyNetworkFilters applies network blocking rules to a WebView.153+// injectCosmeticBaseScript injects the base cosmetic filtering script.
304-func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filterJSON []byte) {154+func (s *ContentBlockingService) injectCosmeticBaseScript(wv *pkgwebkit.WebView) {
305 if wv == nil || wv.IsDestroyed() {155 if wv == nil || wv.IsDestroyed() {
306 return156 return
307 }157 }
@@ -313,64 +163,55 @@ func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filt
313 163
314 ucm := gtkView.UserContentManager()164 ucm := gtkView.UserContentManager()
315 if ucm == nil {165 if ucm == nil {
316- // Reset state so a future ready WebView can retry
317- s.mu.Lock()
318- if state, exists := s.webViews[wv]; exists {
319- state.networkApplied = false
320- s.webViews[wv] = state
321- }
322- s.mu.Unlock()
323 return166 return
324 }167 }
325 168
326- // Use shared identifier so all WebViews reuse the same compiled filter169+ injector := s.cosmeticInjector
327- const identifier = "adblock-filters"170+ if injector == nil || !injector.IsEnabled() {
328-
329- // Try to apply cached compiled filter first (instant)
330- if s.contentBlockingMgr.IsFilterCompiled() {
331- if err := s.contentBlockingMgr.ApplyCompiledFilter(ucm); err != nil {
332- logging.Error(fmt.Sprintf("[filtering] Failed to apply cached filter to WebView %d: %v", wv.ID(), err))
333- return
334- }
335- } else if len(filterJSON) > 0 {
336- // Fallback: compile if not yet compiled (first WebView)
337- err := s.contentBlockingMgr.ApplyFiltersFromJSON(ucm, identifier, filterJSON)
338- if err != nil {
339- logging.Error(fmt.Sprintf("[filtering] Failed to apply network filters to WebView %d: %v", wv.ID(), err))
340- return
341- }
342- } else {
343 return171 return
344 }172 }
345 173
346- logging.Debug(fmt.Sprintf("[filtering] Applied network filters to WebView %d", wv.ID()))174+ // Get base script (empty domain = infrastructure only)
347-}175+ baseScript := injector.GetScriptForDomain("")
348-176+ if baseScript == "" {
349-// setupCosmeticHooks sets up hooks for cosmetic filtering on navigation events.
350-func (s *ContentBlockingService) setupCosmeticHooks(wv *pkgwebkit.WebView) {
351- if wv == nil {
352 return177 return
353 }178 }
354 179
355- // Register load-committed handler to inject domain-specific cosmetic rules180+ // Inject at document-end when DOM is ready
356- webView := wv // capture for closure181+ userScript := webkit.NewUserScript(
357- wv.RegisterLoadCommittedHandler(func(uri string) {182+ baseScript,
358- if webView.IsDestroyed() {183+ webkit.UserContentInjectAllFrames,
359- return184+ webkit.UserScriptInjectAtDocumentEnd,
185+ nil, nil,
186+ )
187+
188+ ucm.AddScript(userScript)
189+ logging.Debug(fmt.Sprintf("[filtering] Injected cosmetic base script into WebView %d", wv.ID()))
190+}
191+
192+// isLocalScheme returns true if the URI uses a local/internal scheme
193+// that doesn't need content filtering.
194+func isLocalScheme(uri string) bool {
195+ for _, scheme := range localSchemes {
196+ if strings.HasPrefix(uri, scheme) {
197+ return true
360 }198 }
361- s.runWhenWidgetReady(webView, func() {199+ }
362- s.injectCosmeticRules(webView, uri)200+ return false
363- })
364- })
365 }201 }
366 202
367-// injectCosmeticRules injects cosmetic filtering rules for the current page.203+// injectCosmeticRules injects domain-specific cosmetic rules.
204+// Called from load-committed handler (already on GTK main thread).
368 func (s *ContentBlockingService) injectCosmeticRules(wv *pkgwebkit.WebView, uri string) {205 func (s *ContentBlockingService) injectCosmeticRules(wv *pkgwebkit.WebView, uri string) {
369 if wv == nil || wv.IsDestroyed() || uri == "" {206 if wv == nil || wv.IsDestroyed() || uri == "" {
370 return207 return
371 }208 }
372 209
373- // Parse domain from URI210+ // Skip local/internal schemes
211+ if isLocalScheme(uri) {
212+ return
213+ }
214+
374 parsedURL, err := url.Parse(uri)215 parsedURL, err := url.Parse(uri)
375 if err != nil || parsedURL.Host == "" {216 if err != nil || parsedURL.Host == "" {
376 return217 return
@@ -378,11 +219,7 @@ func (s *ContentBlockingService) injectCosmeticRules(wv *pkgwebkit.WebView, uri
378 219
379 domain := parsedURL.Hostname()220 domain := parsedURL.Hostname()
380 221
381- // Get cosmetic script for this domain
382- s.mu.RLock()
383 injector := s.cosmeticInjector222 injector := s.cosmeticInjector
384- s.mu.RUnlock()
385-
386 if injector == nil || !injector.IsEnabled() {223 if injector == nil || !injector.IsEnabled() {
387 return224 return
388 }225 }
@@ -392,36 +229,27 @@ func (s *ContentBlockingService) injectCosmeticRules(wv *pkgwebkit.WebView, uri
392 return229 return
393 }230 }
394 231
395- // Inject the script
396 logging.Debug(fmt.Sprintf("[filtering] Injecting cosmetic rules for domain: %s (%d bytes)", domain, len(script)))232 logging.Debug(fmt.Sprintf("[filtering] Injecting cosmetic rules for domain: %s (%d bytes)", domain, len(script)))
397- webView := wv // capture for closure233+
398- pkgwebkit.RunOnMainThread(func() {234+ // Already on GTK main thread from load-committed handler
399- if webView.IsDestroyed() {235+ if err := wv.InjectScript(script); err != nil {
400- return236+ logging.Error(fmt.Sprintf("[filtering] Failed to inject cosmetic rules for %s: %v", domain, err))
401- }237+ } else {
402- if err := webView.InjectScript(script); err != nil {238+ logging.Debug(fmt.Sprintf("[filtering] Successfully injected cosmetic rules for domain: %s", domain))
403- logging.Error(fmt.Sprintf("[filtering] Failed to inject cosmetic rules for %s: %v", domain, err))239+ }
404- } else {
405- logging.Debug(fmt.Sprintf("[filtering] Successfully injected cosmetic rules for domain: %s", domain))
406- }
407- })
408 }240 }
409 241
410 // GetFilterManager returns the underlying filter manager.242 // GetFilterManager returns the underlying filter manager.
411 func (s *ContentBlockingService) GetFilterManager() *FilterManager {243 func (s *ContentBlockingService) GetFilterManager() *FilterManager {
412- s.mu.RLock()
413- defer s.mu.RUnlock()
414 return s.filterManager244 return s.filterManager
415 }245 }
416 246
417 // GetCosmeticInjector returns the cosmetic injector.247 // GetCosmeticInjector returns the cosmetic injector.
418 func (s *ContentBlockingService) GetCosmeticInjector() *cosmetic.CosmeticInjector {248 func (s *ContentBlockingService) GetCosmeticInjector() *cosmetic.CosmeticInjector {
419- s.mu.RLock()
420- defer s.mu.RUnlock()
421 return s.cosmeticInjector249 return s.cosmeticInjector
422 }250 }
423 251
424-// IsReady returns whether filters have been loaded and are ready to apply.252+// IsReady returns whether filters have been loaded.
425 func (s *ContentBlockingService) IsReady() bool {253 func (s *ContentBlockingService) IsReady() bool {
426 s.mu.RLock()254 s.mu.RLock()
427 defer s.mu.RUnlock()255 defer s.mu.RUnlock()
@@ -433,18 +261,9 @@ func (s *ContentBlockingService) GetStats() map[string]interface{} {
433 s.mu.RLock()261 s.mu.RLock()
434 defer s.mu.RUnlock()262 defer s.mu.RUnlock()
435 263
436- webViewCount := len(s.webViews)
437- appliedCount := 0
438- for _, state := range s.webViews {
439- if state.networkApplied {
440- appliedCount++
441- }
442- }
443-
444 stats := map[string]interface{}{264 stats := map[string]interface{}{
445 "ready": s.filtersReady,265 "ready": s.filtersReady,
446- "webview_count": webViewCount,266+ "filter_compiled": s.contentBlockingMgr.IsFilterCompiled(),
447- "filters_applied": appliedCount,
448 "network_rules_bytes": len(s.networkFilterJSON),267 "network_rules_bytes": len(s.networkFilterJSON),
449 }268 }
450 269
M internal/filtering/store.go +19 -7
@@ -110,14 +110,26 @@ func (fs *FileFilterStore) SaveCache(filters *CompiledFilters) error {
110110 return fmt.Errorf("failed to serialize filters: %w", err)
111111 }
112112
113- // Create metadata
113+ // Load existing metadata to preserve SourceVersions
114+ existingMeta, _ := fs.loadMetadata()
115+ var sourceVersions map[string]string
116+ var lastCheckTime time.Time
117+ if existingMeta != nil {
118+ if existingMeta.SourceVersions != nil {
119+ sourceVersions = existingMeta.SourceVersions
120+ }
121+ lastCheckTime = existingMeta.LastCheckTime
122+ }
123+
124+ // Create metadata, preserving existing SourceVersions
114125 metadata := &CacheMetadata{
115- Version: cacheVersion,
116- CreatedAt: time.Now(),
117- LastUsed: time.Now(),
118- DataHash: fmt.Sprintf("%x", sha256.Sum256(data)),
119- // FilterHashes would be populated with source list hashes in a full implementation
120- FilterHashes: []string{},
126+ Version: cacheVersion,
127+ CreatedAt: time.Now(),
128+ LastUsed: time.Now(),
129+ DataHash: fmt.Sprintf("%x", sha256.Sum256(data)),
130+ FilterHashes: []string{},
131+ SourceVersions: sourceVersions,
132+ LastCheckTime: lastCheckTime,
121133 }
122134
123135 // Write cache data atomically
@@ -110,14 +110,26 @@ func (fs *FileFilterStore) SaveCache(filters *CompiledFilters) error {
110 return fmt.Errorf("failed to serialize filters: %w", err)110 return fmt.Errorf("failed to serialize filters: %w", err)
111 }111 }
112 112
113- // Create metadata113+ // Load existing metadata to preserve SourceVersions
114+ existingMeta, _ := fs.loadMetadata()
115+ var sourceVersions map[string]string
116+ var lastCheckTime time.Time
117+ if existingMeta != nil {
118+ if existingMeta.SourceVersions != nil {
119+ sourceVersions = existingMeta.SourceVersions
120+ }
121+ lastCheckTime = existingMeta.LastCheckTime
122+ }
123+
124+ // Create metadata, preserving existing SourceVersions
114 metadata := &CacheMetadata{125 metadata := &CacheMetadata{
115- Version: cacheVersion,126+ Version: cacheVersion,
116- CreatedAt: time.Now(),127+ CreatedAt: time.Now(),
117- LastUsed: time.Now(),128+ LastUsed: time.Now(),
118- DataHash: fmt.Sprintf("%x", sha256.Sum256(data)),129+ DataHash: fmt.Sprintf("%x", sha256.Sum256(data)),
119- // FilterHashes would be populated with source list hashes in a full implementation130+ FilterHashes: []string{},
120- FilterHashes: []string{},131+ SourceVersions: sourceVersions,
132+ LastCheckTime: lastCheckTime,
121 }133 }
122 134
123 // Write cache data atomically135 // Write cache data atomically
M pkg/webkit/content_blocking.go +52 -4
@@ -4,6 +4,7 @@ import (
44 "context"
55 "fmt"
66 "path/filepath"
7+ "runtime"
78 "sync"
89
910 "github.com/bnema/dumber/internal/logging"
@@ -21,6 +22,7 @@ type ContentBlockingManager struct {
2122 mu sync.RWMutex
2223 compiledFilter *webkit.UserContentFilter
2324 filterID string
25+ contentHash string
2426 }
2527
2628 // NewContentBlockingManager creates a new content blocking manager
@@ -56,6 +58,30 @@ func (cbm *ContentBlockingManager) CompileFilters(identifier string, jsonRules [
5658 cbm.filterID = identifier
5759 cbm.mu.Unlock()
5860
61+ // Check if content matches existing compiled filter
62+ // Use simple length check as quick pre-filter, need hash for real check
63+ // But since we don't have hash function imported, we rely on the fact that
64+ // if we are called, it's usually new content.
65+ // Actually, let's implement a simple check to avoid churn.
66+ hash := fmt.Sprintf("%s-%d", identifier, len(jsonRules))
67+ cbm.mu.RLock()
68+ currentHash := cbm.contentHash
69+ isCompiled := cbm.compiledFilter != nil
70+ cbm.mu.RUnlock()
71+
72+ if isCompiled && currentHash == hash {
73+ logging.Debug(fmt.Sprintf("[webkit] Filter compilation skipped (content unchanged: %s)", hash))
74+ if onComplete != nil {
75+ onComplete(nil)
76+ }
77+ return
78+ }
79+
80+ // update hash
81+ cbm.mu.Lock()
82+ cbm.contentHash = hash
83+ cbm.mu.Unlock()
84+
5985 // Convert []byte to *glib.Bytes for WebKit API
6086 gBytes := glib.NewBytesWithGo(jsonRules)
6187
@@ -85,15 +111,22 @@ func (cbm *ContentBlockingManager) CompileFilters(identifier string, jsonRules [
85111 cbm.compiledFilter = filter
86112 cbm.mu.Unlock()
87113
114+ // Prevent GC from collecting filter prematurely (Go wrapper)
115+ runtime.KeepAlive(filter)
116+
88117 logging.Debug(fmt.Sprintf("[webkit] Filter compilation complete: %s", identifier))
89- if onComplete != nil {
90- onComplete(nil)
91- }
118+
119+ // Run completion handler on main thread to avoid blocking GIO thread
120+ RunOnMainThread(func() {
121+ if onComplete != nil {
122+ onComplete(nil)
123+ }
124+ })
92125 })
93126 }
94127
95128 // ApplyCompiledFilter applies the pre-compiled filter to a UserContentManager.
96-// This is instant since the filter is already compiled.
129+// Uses the cached filter directly - simpler and avoids async callback issues.
97130 func (cbm *ContentBlockingManager) ApplyCompiledFilter(ucm *webkit.UserContentManager) error {
98131 if ucm == nil {
99132 return fmt.Errorf("UserContentManager is nil")
@@ -107,7 +140,13 @@ func (cbm *ContentBlockingManager) ApplyCompiledFilter(ucm *webkit.UserContentMa
107140 return fmt.Errorf("no compiled filter available")
108141 }
109142
143+ // Apply the cached filter directly - UCM keeps its own reference
110144 ucm.AddFilter(filter)
145+ logging.Debug("[webkit] Applied cached content filter to UCM")
146+
147+ // Keep filter alive after AddFilter call
148+ runtime.KeepAlive(filter)
149+
111150 return nil
112151 }
113152
@@ -167,10 +206,19 @@ func (cbm *ContentBlockingManager) ApplyFiltersFromJSON(ucm *webkit.UserContentM
167206
168207 // Cache for future use (only valid filters)
169208 cbm.mu.Lock()
209+ oldFilter := cbm.compiledFilter
170210 cbm.compiledFilter = filter
171211 cbm.filterID = identifier
172212 cbm.mu.Unlock()
173213
214+ // Manually ref the new filter
215+ RefUserContentFilter(filter)
216+
217+ // Unref old filter if replaced
218+ if oldFilter != nil {
219+ UnrefUserContentFilter(oldFilter)
220+ }
221+
174222 RunOnMainThread(func() {
175223 if filter != nil {
176224 ucm.AddFilter(filter)
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "path/filepath"6 "path/filepath"
7+ "runtime"
7 "sync"8 "sync"
8 9
9 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
@@ -21,6 +22,7 @@ type ContentBlockingManager struct {
21 mu sync.RWMutex22 mu sync.RWMutex
22 compiledFilter *webkit.UserContentFilter23 compiledFilter *webkit.UserContentFilter
23 filterID string24 filterID string
25+ contentHash string
24 }26 }
25 27
26 // NewContentBlockingManager creates a new content blocking manager28 // NewContentBlockingManager creates a new content blocking manager
@@ -56,6 +58,30 @@ func (cbm *ContentBlockingManager) CompileFilters(identifier string, jsonRules [
56 cbm.filterID = identifier58 cbm.filterID = identifier
57 cbm.mu.Unlock()59 cbm.mu.Unlock()
58 60
61+ // Check if content matches existing compiled filter
62+ // Use simple length check as quick pre-filter, need hash for real check
63+ // But since we don't have hash function imported, we rely on the fact that
64+ // if we are called, it's usually new content.
65+ // Actually, let's implement a simple check to avoid churn.
66+ hash := fmt.Sprintf("%s-%d", identifier, len(jsonRules))
67+ cbm.mu.RLock()
68+ currentHash := cbm.contentHash
69+ isCompiled := cbm.compiledFilter != nil
70+ cbm.mu.RUnlock()
71+
72+ if isCompiled && currentHash == hash {
73+ logging.Debug(fmt.Sprintf("[webkit] Filter compilation skipped (content unchanged: %s)", hash))
74+ if onComplete != nil {
75+ onComplete(nil)
76+ }
77+ return
78+ }
79+
80+ // update hash
81+ cbm.mu.Lock()
82+ cbm.contentHash = hash
83+ cbm.mu.Unlock()
84+
59 // Convert []byte to *glib.Bytes for WebKit API85 // Convert []byte to *glib.Bytes for WebKit API
60 gBytes := glib.NewBytesWithGo(jsonRules)86 gBytes := glib.NewBytesWithGo(jsonRules)
61 87
@@ -85,15 +111,22 @@ func (cbm *ContentBlockingManager) CompileFilters(identifier string, jsonRules [
85 cbm.compiledFilter = filter111 cbm.compiledFilter = filter
86 cbm.mu.Unlock()112 cbm.mu.Unlock()
87 113
114+ // Prevent GC from collecting filter prematurely (Go wrapper)
115+ runtime.KeepAlive(filter)
116+
88 logging.Debug(fmt.Sprintf("[webkit] Filter compilation complete: %s", identifier))117 logging.Debug(fmt.Sprintf("[webkit] Filter compilation complete: %s", identifier))
89- if onComplete != nil {118+
90- onComplete(nil)119+ // Run completion handler on main thread to avoid blocking GIO thread
91- }120+ RunOnMainThread(func() {
121+ if onComplete != nil {
122+ onComplete(nil)
123+ }
124+ })
92 })125 })
93 }126 }
94 127
95 // ApplyCompiledFilter applies the pre-compiled filter to a UserContentManager.128 // ApplyCompiledFilter applies the pre-compiled filter to a UserContentManager.
96-// This is instant since the filter is already compiled.129+// Uses the cached filter directly - simpler and avoids async callback issues.
97 func (cbm *ContentBlockingManager) ApplyCompiledFilter(ucm *webkit.UserContentManager) error {130 func (cbm *ContentBlockingManager) ApplyCompiledFilter(ucm *webkit.UserContentManager) error {
98 if ucm == nil {131 if ucm == nil {
99 return fmt.Errorf("UserContentManager is nil")132 return fmt.Errorf("UserContentManager is nil")
@@ -107,7 +140,13 @@ func (cbm *ContentBlockingManager) ApplyCompiledFilter(ucm *webkit.UserContentMa
107 return fmt.Errorf("no compiled filter available")140 return fmt.Errorf("no compiled filter available")
108 }141 }
109 142
143+ // Apply the cached filter directly - UCM keeps its own reference
110 ucm.AddFilter(filter)144 ucm.AddFilter(filter)
145+ logging.Debug("[webkit] Applied cached content filter to UCM")
146+
147+ // Keep filter alive after AddFilter call
148+ runtime.KeepAlive(filter)
149+
111 return nil150 return nil
112 }151 }
113 152
@@ -167,10 +206,19 @@ func (cbm *ContentBlockingManager) ApplyFiltersFromJSON(ucm *webkit.UserContentM
167 206
168 // Cache for future use (only valid filters)207 // Cache for future use (only valid filters)
169 cbm.mu.Lock()208 cbm.mu.Lock()
209+ oldFilter := cbm.compiledFilter
170 cbm.compiledFilter = filter210 cbm.compiledFilter = filter
171 cbm.filterID = identifier211 cbm.filterID = identifier
172 cbm.mu.Unlock()212 cbm.mu.Unlock()
173 213
214+ // Manually ref the new filter
215+ RefUserContentFilter(filter)
216+
217+ // Unref old filter if replaced
218+ if oldFilter != nil {
219+ UnrefUserContentFilter(oldFilter)
220+ }
221+
174 RunOnMainThread(func() {222 RunOnMainThread(func() {
175 if filter != nil {223 if filter != nil {
176 ucm.AddFilter(filter)224 ucm.AddFilter(filter)
A pkg/webkit/memory_fixes.go +22 -0
@@ -0,0 +1,22 @@
1+package webkit
2+
3+/*
4+#cgo pkg-config: webkitgtk-6.0
5+#include <webkit/webkit.h>
6+*/
7+import "C"
8+import (
9+ "fmt"
10+ "unsafe"
11+
12+ "github.com/bnema/dumber/internal/logging"
13+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
14+ "github.com/diamondburned/gotk4/pkg/core/gextras"
15+
16+// RefUserContentFilter is now a no-op as we rely on Go GC and persistent wrappers
17+func RefUserContentFilter(filter *webkit.UserContentFilter) {
18+}
19+
20+// UnrefUserContentFilter is now a no-op as we rely on Go GC and persistent wrappers
21+func UnrefUserContentFilter(filter *webkit.UserContentFilter) {
22+}
@@ -0,0 +1,22 @@
1+package webkit
2+
3+/*
4+#cgo pkg-config: webkitgtk-6.0
5+#include <webkit/webkit.h>
6+*/
7+import "C"
8+import (
9+ "fmt"
10+ "unsafe"
11+
12+ "github.com/bnema/dumber/internal/logging"
13+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
14+ "github.com/diamondburned/gotk4/pkg/core/gextras"
15+
16+// RefUserContentFilter is now a no-op as we rely on Go GC and persistent wrappers
17+func RefUserContentFilter(filter *webkit.UserContentFilter) {
18+}
19+
20+// UnrefUserContentFilter is now a no-op as we rely on Go GC and persistent wrappers
21+func UnrefUserContentFilter(filter *webkit.UserContentFilter) {
22+}

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