detached 4f987b8

fix(filtering): stabilize content blocking application and injection

commit
4f987b805f47d338bd0a08a72441097f5a2b3aa5
author
bnema · · 2025-12-07 19:25 UTC
parent
fefcc69
signature
unsigned
3 files · +170 -28
M internal/filtering/cosmetic/injector.go +24 -0
@@ -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
@@ -40,6 +42,9 @@ func (ci *CosmeticInjector) InjectRules(rules map[string][]string) error {
4042 return fmt.Errorf("cosmetic filtering disabled")
4143 }
4244
45+ ci.mu.Lock()
46+ defer ci.mu.Unlock()
47+
4348 // Clear existing rules
4449 ci.domainRules = make(map[string][]string)
4550 ci.genericRules = make([]string, 0)
@@ -67,6 +72,9 @@ func (ci *CosmeticInjector) GetScriptForDomain(domain string) string {
6772 return ""
6873 }
6974
75+ ci.mu.RLock()
76+ defer ci.mu.RUnlock()
77+
7078 // Build rules for this specific domain (including parent domains)
7179 var rules []CosmeticRule
7280
@@ -134,6 +142,9 @@ func (ci *CosmeticInjector) UpdateRulesForDomain(domain string, newSelectors []s
134142 return
135143 }
136144
145+ ci.mu.Lock()
146+ defer ci.mu.Unlock()
147+
137148 if len(newSelectors) == 0 {
138149 return
139150 }
@@ -158,6 +169,9 @@ func (ci *CosmeticInjector) GetUpdateScript(newSelectors []string) string {
158169 return ""
159170 }
160171
172+ ci.mu.RLock()
173+ defer ci.mu.RUnlock()
174+
161175 selectorsJSON, err := json.Marshal(newSelectors)
162176 if err != nil {
163177 logging.Error(fmt.Sprintf("Failed to marshal update selectors: %v", err))
@@ -190,6 +204,9 @@ func (ci *CosmeticInjector) GetCleanupScript() string {
190204
191205 // GetRulesForDomain returns all cosmetic rules that apply to a domain
192206 func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string {
207+ ci.mu.RLock()
208+ defer ci.mu.RUnlock()
209+
193210 var rules []string
194211
195212 // Add generic rules
@@ -215,6 +232,9 @@ func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string {
215232
216233 // Enable enables or disables cosmetic filtering
217234 func (ci *CosmeticInjector) Enable(enabled bool) {
235+ ci.mu.Lock()
236+ defer ci.mu.Unlock()
237+
218238 ci.scriptEnabled = enabled
219239 if enabled {
220240 logging.Info("Cosmetic filtering enabled")
@@ -225,11 +245,15 @@ func (ci *CosmeticInjector) Enable(enabled bool) {
225245
226246 // IsEnabled returns whether cosmetic filtering is enabled
227247 func (ci *CosmeticInjector) IsEnabled() bool {
248+ ci.mu.RLock()
249+ defer ci.mu.RUnlock()
228250 return ci.scriptEnabled
229251 }
230252
231253 // GetStats returns statistics about loaded cosmetic rules
232254 func (ci *CosmeticInjector) GetStats() map[string]interface{} {
255+ ci.mu.RLock()
256+ defer ci.mu.RUnlock()
233257 domainCount := len(ci.domainRules)
234258 totalRules := len(ci.genericRules)
235259
@@ -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
@@ -40,6 +42,9 @@ func (ci *CosmeticInjector) InjectRules(rules map[string][]string) error {
40 return fmt.Errorf("cosmetic filtering disabled")42 return fmt.Errorf("cosmetic filtering disabled")
41 }43 }
42 44
45+ ci.mu.Lock()
46+ defer ci.mu.Unlock()
47+
43 // Clear existing rules48 // Clear existing rules
44 ci.domainRules = make(map[string][]string)49 ci.domainRules = make(map[string][]string)
45 ci.genericRules = make([]string, 0)50 ci.genericRules = make([]string, 0)
@@ -67,6 +72,9 @@ func (ci *CosmeticInjector) GetScriptForDomain(domain string) string {
67 return ""72 return ""
68 }73 }
69 74
75+ ci.mu.RLock()
76+ defer ci.mu.RUnlock()
77+
70 // Build rules for this specific domain (including parent domains)78 // Build rules for this specific domain (including parent domains)
71 var rules []CosmeticRule79 var rules []CosmeticRule
72 80
@@ -134,6 +142,9 @@ func (ci *CosmeticInjector) UpdateRulesForDomain(domain string, newSelectors []s
134 return142 return
135 }143 }
136 144
145+ ci.mu.Lock()
146+ defer ci.mu.Unlock()
147+
137 if len(newSelectors) == 0 {148 if len(newSelectors) == 0 {
138 return149 return
139 }150 }
@@ -158,6 +169,9 @@ func (ci *CosmeticInjector) GetUpdateScript(newSelectors []string) string {
158 return ""169 return ""
159 }170 }
160 171
172+ ci.mu.RLock()
173+ defer ci.mu.RUnlock()
174+
161 selectorsJSON, err := json.Marshal(newSelectors)175 selectorsJSON, err := json.Marshal(newSelectors)
162 if err != nil {176 if err != nil {
163 logging.Error(fmt.Sprintf("Failed to marshal update selectors: %v", err))177 logging.Error(fmt.Sprintf("Failed to marshal update selectors: %v", err))
@@ -190,6 +204,9 @@ func (ci *CosmeticInjector) GetCleanupScript() string {
190 204
191 // GetRulesForDomain returns all cosmetic rules that apply to a domain205 // GetRulesForDomain returns all cosmetic rules that apply to a domain
192 func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string {206 func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string {
207+ ci.mu.RLock()
208+ defer ci.mu.RUnlock()
209+
193 var rules []string210 var rules []string
194 211
195 // Add generic rules212 // Add generic rules
@@ -215,6 +232,9 @@ func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string {
215 232
216 // Enable enables or disables cosmetic filtering233 // Enable enables or disables cosmetic filtering
217 func (ci *CosmeticInjector) Enable(enabled bool) {234 func (ci *CosmeticInjector) Enable(enabled bool) {
235+ ci.mu.Lock()
236+ defer ci.mu.Unlock()
237+
218 ci.scriptEnabled = enabled238 ci.scriptEnabled = enabled
219 if enabled {239 if enabled {
220 logging.Info("Cosmetic filtering enabled")240 logging.Info("Cosmetic filtering enabled")
@@ -225,11 +245,15 @@ func (ci *CosmeticInjector) Enable(enabled bool) {
225 245
226 // IsEnabled returns whether cosmetic filtering is enabled246 // IsEnabled returns whether cosmetic filtering is enabled
227 func (ci *CosmeticInjector) IsEnabled() bool {247 func (ci *CosmeticInjector) IsEnabled() bool {
248+ ci.mu.RLock()
249+ defer ci.mu.RUnlock()
228 return ci.scriptEnabled250 return ci.scriptEnabled
229 }251 }
230 252
231 // GetStats returns statistics about loaded cosmetic rules253 // GetStats returns statistics about loaded cosmetic rules
232 func (ci *CosmeticInjector) GetStats() map[string]interface{} {254 func (ci *CosmeticInjector) GetStats() map[string]interface{} {
255+ ci.mu.RLock()
256+ defer ci.mu.RUnlock()
233 domainCount := len(ci.domainRules)257 domainCount := len(ci.domainRules)
234 totalRules := len(ci.genericRules)258 totalRules := len(ci.genericRules)
235 259
M internal/filtering/manager.go +4 -4
@@ -297,9 +297,6 @@ 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)
303300 }
304301 }
305302
@@ -307,6 +304,9 @@ func (fm *FilterManager) compileFromSources(ctx context.Context) {
307304 if hasContent {
308305 logging.Info(fmt.Sprintf("[filtering] Successfully compiled %d network rules from sources", len(merged.NetworkRules)))
309306
307+ // Apply final merged filters once after all sources are processed
308+ fm.applyFilters(merged)
309+
310310 // Cache the final result
311311 if err := fm.store.SaveCache(merged); err != nil {
312312 logging.Error(fmt.Sprintf("Failed to save filter cache: %v", err))
@@ -405,7 +405,7 @@ func (fm *FilterManager) applyFilters(filters *CompiledFilters) {
405405 logging.Info("[filtering] Content filtering is DISABLED in config - no ad blocking active")
406406 return
407407 }
408-
408+ // Re-enable database-driven whitelist injection (ignore-previous-rules) now that filtering apply is stabilized.
409409 fm.updateWhitelistCache()
410410 if len(fm.cachedWhitelistRules) > 0 {
411411 filters.NetworkRules = append(filters.NetworkRules, fm.cachedWhitelistRules...)
@@ -297,9 +297,6 @@ 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 blocking
301- logging.Info(fmt.Sprintf("[filtering] Applying incremental filters: %d network rules", len(merged.NetworkRules)))
302- fm.applyFilters(merged)
303 }300 }
304 }301 }
305 302
@@ -307,6 +304,9 @@ func (fm *FilterManager) compileFromSources(ctx context.Context) {
307 if hasContent {304 if hasContent {
308 logging.Info(fmt.Sprintf("[filtering] Successfully compiled %d network rules from sources", len(merged.NetworkRules)))305 logging.Info(fmt.Sprintf("[filtering] Successfully compiled %d network rules from sources", len(merged.NetworkRules)))
309 306
307+ // Apply final merged filters once after all sources are processed
308+ fm.applyFilters(merged)
309+
310 // Cache the final result310 // Cache the final result
311 if err := fm.store.SaveCache(merged); err != nil {311 if err := fm.store.SaveCache(merged); err != nil {
312 logging.Error(fmt.Sprintf("Failed to save filter cache: %v", err))312 logging.Error(fmt.Sprintf("Failed to save filter cache: %v", err))
@@ -405,7 +405,7 @@ func (fm *FilterManager) applyFilters(filters *CompiledFilters) {
405 logging.Info("[filtering] Content filtering is DISABLED in config - no ad blocking active")405 logging.Info("[filtering] Content filtering is DISABLED in config - no ad blocking active")
406 return406 return
407 }407 }
408-408+ // Re-enable database-driven whitelist injection (ignore-previous-rules) now that filtering apply is stabilized.
409 fm.updateWhitelistCache()409 fm.updateWhitelistCache()
410 if len(fm.cachedWhitelistRules) > 0 {410 if len(fm.cachedWhitelistRules) > 0 {
411 filters.NetworkRules = append(filters.NetworkRules, fm.cachedWhitelistRules...)411 filters.NetworkRules = append(filters.NetworkRules, fm.cachedWhitelistRules...)
M internal/filtering/service.go +142 -24
@@ -2,9 +2,11 @@
22 package filtering
33
44 import (
5+ "crypto/sha256"
56 "fmt"
67 "net/url"
78 "sync"
9+ "time"
810
911 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
1012 glib "github.com/diamondburned/gotk4/pkg/glib/v2"
@@ -67,6 +69,9 @@ type ContentBlockingService struct {
6769
6870 // Filter data
6971 networkFilterJSON []byte
72+ networkHash string // hash of current networkFilterJSON to dedupe compiles
73+ compileInProgress bool
74+ compileDone chan struct{} // closed when current payload compile completes (success or error)
7075 filterManager *FilterManager
7176 cosmeticInjector *cosmetic.CosmeticInjector
7277 filtersReady bool
@@ -74,6 +79,9 @@ type ContentBlockingService struct {
7479 // WebView registry - tracks all active WebViews
7580 webViews map[*pkgwebkit.WebView]webViewState
7681
82+ // Serializes application of compiled filters to WebKit to avoid concurrent CGO calls.
83+ applyMu sync.Mutex
84+
7785 // Content blocking manager for compiling filters
7886 contentBlockingMgr *pkgwebkit.ContentBlockingManager
7987
@@ -112,13 +120,96 @@ func NewContentBlockingService(dataDir string, filterManager *FilterManager) (*C
112120 // SetFiltersReady is called when filters have been parsed and are ready to compile.
113121 // It compiles filters asynchronously, then applies to all registered WebViews.
114122 func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) {
123+ // Compute hash to detect identical filter payloads and avoid redundant compiles
124+ hash := fmt.Sprintf("%x", sha256.Sum256(networkJSON))
125+
115126 s.mu.Lock()
127+ // If a compile is already in progress:
128+ if s.compileInProgress {
129+ // If it's the same payload, just ensure cosmetics are injected and return.
130+ if s.networkHash == hash {
131+ webViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
132+ for wv := range s.webViews {
133+ webViews = append(webViews, wv)
134+ }
135+ s.mu.Unlock()
136+ for _, wv := range webViews {
137+ webView := wv
138+ s.runWhenWidgetReady(webView, func() {
139+ s.injectCosmeticBaseScript(webView)
140+ if uri := webView.GetCurrentURL(); uri != "" {
141+ s.injectCosmeticRules(webView, uri)
142+ }
143+ })
144+ }
145+ return
146+ }
147+ // Different payload while another compile is running: skip recompile for now to avoid races.
148+ // Cosmetic-only injection will still happen below after unlocking.
149+ webViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
150+ for wv := range s.webViews {
151+ webViews = append(webViews, wv)
152+ }
153+ s.mu.Unlock()
154+ for _, wv := range webViews {
155+ webView := wv
156+ s.runWhenWidgetReady(webView, func() {
157+ s.injectCosmeticBaseScript(webView)
158+ if uri := webView.GetCurrentURL(); uri != "" {
159+ s.injectCosmeticRules(webView, uri)
160+ }
161+ })
162+ }
163+ return
164+ }
165+
166+ if s.networkHash == hash && s.contentBlockingMgr.IsFilterCompiled() {
167+ // Already compiled and applied; just mark ready and apply cosmetic scripts
168+ s.filtersReady = true
169+ webViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
170+ for wv := range s.webViews {
171+ webViews = append(webViews, wv)
172+ }
173+ s.mu.Unlock()
174+
175+ for _, wv := range webViews {
176+ webView := wv
177+ s.runWhenWidgetReady(webView, func() {
178+ s.injectCosmeticBaseScript(webView)
179+ if uri := webView.GetCurrentURL(); uri != "" {
180+ s.injectCosmeticRules(webView, uri)
181+ }
182+ s.applyFiltersToWebView(webView)
183+ })
184+ }
185+ return
186+ }
187+
188+ // Update filter payload and mark ready
116189 s.networkFilterJSON = networkJSON
117- s.filtersReady = true // Cosmetic rules are ready immediately
190+ s.networkHash = hash
191+ s.filtersReady = true
118192 webViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
119193 for wv := range s.webViews {
120194 webViews = append(webViews, wv)
121195 }
196+ // If a compile is already running for this payload, just do cosmetic now and return
197+ if s.compileInProgress {
198+ s.mu.Unlock()
199+ for _, wv := range webViews {
200+ webView := wv
201+ s.runWhenWidgetReady(webView, func() {
202+ s.injectCosmeticBaseScript(webView)
203+ if uri := webView.GetCurrentURL(); uri != "" {
204+ s.injectCosmeticRules(webView, uri)
205+ }
206+ })
207+ }
208+ return
209+ }
210+ // New payload, no compile in progress: create a fresh completion channel
211+ s.compileDone = make(chan struct{})
212+ s.compileInProgress = true
122213 s.mu.Unlock()
123214
124215 // Inject cosmetic scripts IMMEDIATELY (don't wait for network filter compilation)
@@ -141,29 +232,26 @@ func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) {
141232
142233 // Compile network filters asynchronously - this happens in the background
143234 // and does not block the main thread (takes ~6 seconds)
144- s.contentBlockingMgr.CompileFilters("adblock-filters", networkJSON, func(err error) {
145- if err != nil {
146- logging.Error(fmt.Sprintf("[filtering] Filter compilation failed: %v", err))
147- return
148- }
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()
235+ pkgwebkit.RunOnMainThread(func() {
236+ s.contentBlockingMgr.CompileFilters("adblock-filters", networkJSON, func(err error) {
237+ s.mu.Lock()
238+ s.compileInProgress = false
239+ done := s.compileDone
240+ s.mu.Unlock()
241+
242+ if err != nil {
243+ logging.Error(fmt.Sprintf("[filtering] Filter compilation failed: %v", err))
244+ if done != nil {
245+ close(done)
246+ }
247+ return
248+ }
159249
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- }
250+ logging.Info("[filtering] Network filter compilation complete")
251+ if done != nil {
252+ close(done)
253+ }
254+ })
167255 })
168256 }
169257
@@ -269,7 +357,27 @@ func (s *ContentBlockingService) applyFiltersToWebView(wv *pkgwebkit.WebView) {
269357 }
270358
271359 // Avoid triggering compilation on the GTK thread; only apply once compiled.
272- if !s.contentBlockingMgr.IsFilterCompiled() {
360+ s.mu.RLock()
361+ compiled := s.contentBlockingMgr.IsFilterCompiled()
362+ done := s.compileDone
363+ s.mu.RUnlock()
364+ if !compiled {
365+ // Wait for current compile to finish, then try again (with timeout to avoid hangs)
366+ if done == nil {
367+ return
368+ }
369+ go func(ch <-chan struct{}, view *pkgwebkit.WebView) {
370+ select {
371+ case <-ch:
372+ // Re-attempt apply after compile completes
373+ if view != nil && !view.IsDestroyed() {
374+ s.applyFiltersToWebView(view)
375+ }
376+ case <-time.After(10 * time.Second):
377+ // Timed out waiting for compile; skip
378+ logging.Warn("[filtering] Timeout waiting for filter compilation before applying to WebView")
379+ }
380+ }(done, wv)
273381 return
274382 }
275383
@@ -306,6 +414,11 @@ func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filt
306414 return
307415 }
308416
417+ // Double-check compile state before proceeding
418+ if !s.contentBlockingMgr.IsFilterCompiled() {
419+ return
420+ }
421+
309422 gtkView := wv.GetWebView()
310423 if gtkView == nil {
311424 return
@@ -327,6 +440,9 @@ func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filt
327440 const identifier = "adblock-filters"
328441
329442 // Try to apply cached compiled filter first (instant)
443+ s.applyMu.Lock()
444+ defer s.applyMu.Unlock()
445+
330446 if s.contentBlockingMgr.IsFilterCompiled() {
331447 if err := s.contentBlockingMgr.ApplyCompiledFilter(ucm); err != nil {
332448 logging.Error(fmt.Sprintf("[filtering] Failed to apply cached filter to WebView %d: %v", wv.ID(), err))
@@ -360,6 +476,8 @@ func (s *ContentBlockingService) setupCosmeticHooks(wv *pkgwebkit.WebView) {
360476 }
361477 s.runWhenWidgetReady(webView, func() {
362478 s.injectCosmeticRules(webView, uri)
479+ // Apply network filters lazily on navigation when compilation is ready
480+ s.applyFiltersToWebView(webView)
363481 })
364482 })
365483 }
@@ -2,9 +2,11 @@
2 package filtering2 package filtering
3 3
4 import (4 import (
5+ "crypto/sha256"
5 "fmt"6 "fmt"
6 "net/url"7 "net/url"
7 "sync"8 "sync"
9+ "time"
8 10
9 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"11 webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
10 glib "github.com/diamondburned/gotk4/pkg/glib/v2"12 glib "github.com/diamondburned/gotk4/pkg/glib/v2"
@@ -67,6 +69,9 @@ type ContentBlockingService struct {
67 69
68 // Filter data70 // Filter data
69 networkFilterJSON []byte71 networkFilterJSON []byte
72+ networkHash string // hash of current networkFilterJSON to dedupe compiles
73+ compileInProgress bool
74+ compileDone chan struct{} // closed when current payload compile completes (success or error)
70 filterManager *FilterManager75 filterManager *FilterManager
71 cosmeticInjector *cosmetic.CosmeticInjector76 cosmeticInjector *cosmetic.CosmeticInjector
72 filtersReady bool77 filtersReady bool
@@ -74,6 +79,9 @@ type ContentBlockingService struct {
74 // WebView registry - tracks all active WebViews79 // WebView registry - tracks all active WebViews
75 webViews map[*pkgwebkit.WebView]webViewState80 webViews map[*pkgwebkit.WebView]webViewState
76 81
82+ // Serializes application of compiled filters to WebKit to avoid concurrent CGO calls.
83+ applyMu sync.Mutex
84+
77 // Content blocking manager for compiling filters85 // Content blocking manager for compiling filters
78 contentBlockingMgr *pkgwebkit.ContentBlockingManager86 contentBlockingMgr *pkgwebkit.ContentBlockingManager
79 87
@@ -112,13 +120,96 @@ func NewContentBlockingService(dataDir string, filterManager *FilterManager) (*C
112 // SetFiltersReady is called when filters have been parsed and are ready to compile.120 // SetFiltersReady is called when filters have been parsed and are ready to compile.
113 // It compiles filters asynchronously, then applies to all registered WebViews.121 // It compiles filters asynchronously, then applies to all registered WebViews.
114 func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) {122 func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) {
123+ // Compute hash to detect identical filter payloads and avoid redundant compiles
124+ hash := fmt.Sprintf("%x", sha256.Sum256(networkJSON))
125+
115 s.mu.Lock()126 s.mu.Lock()
127+ // If a compile is already in progress:
128+ if s.compileInProgress {
129+ // If it's the same payload, just ensure cosmetics are injected and return.
130+ if s.networkHash == hash {
131+ webViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
132+ for wv := range s.webViews {
133+ webViews = append(webViews, wv)
134+ }
135+ s.mu.Unlock()
136+ for _, wv := range webViews {
137+ webView := wv
138+ s.runWhenWidgetReady(webView, func() {
139+ s.injectCosmeticBaseScript(webView)
140+ if uri := webView.GetCurrentURL(); uri != "" {
141+ s.injectCosmeticRules(webView, uri)
142+ }
143+ })
144+ }
145+ return
146+ }
147+ // Different payload while another compile is running: skip recompile for now to avoid races.
148+ // Cosmetic-only injection will still happen below after unlocking.
149+ webViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
150+ for wv := range s.webViews {
151+ webViews = append(webViews, wv)
152+ }
153+ s.mu.Unlock()
154+ for _, wv := range webViews {
155+ webView := wv
156+ s.runWhenWidgetReady(webView, func() {
157+ s.injectCosmeticBaseScript(webView)
158+ if uri := webView.GetCurrentURL(); uri != "" {
159+ s.injectCosmeticRules(webView, uri)
160+ }
161+ })
162+ }
163+ return
164+ }
165+
166+ if s.networkHash == hash && s.contentBlockingMgr.IsFilterCompiled() {
167+ // Already compiled and applied; just mark ready and apply cosmetic scripts
168+ s.filtersReady = true
169+ webViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
170+ for wv := range s.webViews {
171+ webViews = append(webViews, wv)
172+ }
173+ s.mu.Unlock()
174+
175+ for _, wv := range webViews {
176+ webView := wv
177+ s.runWhenWidgetReady(webView, func() {
178+ s.injectCosmeticBaseScript(webView)
179+ if uri := webView.GetCurrentURL(); uri != "" {
180+ s.injectCosmeticRules(webView, uri)
181+ }
182+ s.applyFiltersToWebView(webView)
183+ })
184+ }
185+ return
186+ }
187+
188+ // Update filter payload and mark ready
116 s.networkFilterJSON = networkJSON189 s.networkFilterJSON = networkJSON
117- s.filtersReady = true // Cosmetic rules are ready immediately190+ s.networkHash = hash
191+ s.filtersReady = true
118 webViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))192 webViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))
119 for wv := range s.webViews {193 for wv := range s.webViews {
120 webViews = append(webViews, wv)194 webViews = append(webViews, wv)
121 }195 }
196+ // If a compile is already running for this payload, just do cosmetic now and return
197+ if s.compileInProgress {
198+ s.mu.Unlock()
199+ for _, wv := range webViews {
200+ webView := wv
201+ s.runWhenWidgetReady(webView, func() {
202+ s.injectCosmeticBaseScript(webView)
203+ if uri := webView.GetCurrentURL(); uri != "" {
204+ s.injectCosmeticRules(webView, uri)
205+ }
206+ })
207+ }
208+ return
209+ }
210+ // New payload, no compile in progress: create a fresh completion channel
211+ s.compileDone = make(chan struct{})
212+ s.compileInProgress = true
122 s.mu.Unlock()213 s.mu.Unlock()
123 214
124 // Inject cosmetic scripts IMMEDIATELY (don't wait for network filter compilation)215 // Inject cosmetic scripts IMMEDIATELY (don't wait for network filter compilation)
@@ -141,29 +232,26 @@ func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) {
141 232
142 // Compile network filters asynchronously - this happens in the background233 // Compile network filters asynchronously - this happens in the background
143 // and does not block the main thread (takes ~6 seconds)234 // and does not block the main thread (takes ~6 seconds)
144- s.contentBlockingMgr.CompileFilters("adblock-filters", networkJSON, func(err error) {235+ pkgwebkit.RunOnMainThread(func() {
145- if err != nil {236+ s.contentBlockingMgr.CompileFilters("adblock-filters", networkJSON, func(err error) {
146- logging.Error(fmt.Sprintf("[filtering] Filter compilation failed: %v", err))237+ s.mu.Lock()
147- return238+ s.compileInProgress = false
148- }239+ done := s.compileDone
149-240+ s.mu.Unlock()
150- logging.Info("[filtering] Network filter compilation complete, applying to WebViews")241+
151-242+ if err != nil {
152- // Get current WebViews (may have changed since cosmetic injection)243+ logging.Error(fmt.Sprintf("[filtering] Filter compilation failed: %v", err))
153- s.mu.RLock()244+ if done != nil {
154- currentWebViews := make([]*pkgwebkit.WebView, 0, len(s.webViews))245+ close(done)
155- for wv := range s.webViews {246+ }
156- currentWebViews = append(currentWebViews, wv)247+ return
157- }248+ }
158- s.mu.RUnlock()
159 249
160- // Apply network filters to all registered WebViews250+ logging.Info("[filtering] Network filter compilation complete")
161- for _, wv := range currentWebViews {251+ if done != nil {
162- webView := wv // capture for closure252+ close(done)
163- s.runWhenWidgetReady(webView, func() {253+ }
164- s.applyFiltersToWebView(webView)254+ })
165- })
166- }
167 })255 })
168 }256 }
169 257
@@ -269,7 +357,27 @@ func (s *ContentBlockingService) applyFiltersToWebView(wv *pkgwebkit.WebView) {
269 }357 }
270 358
271 // Avoid triggering compilation on the GTK thread; only apply once compiled.359 // Avoid triggering compilation on the GTK thread; only apply once compiled.
272- if !s.contentBlockingMgr.IsFilterCompiled() {360+ s.mu.RLock()
361+ compiled := s.contentBlockingMgr.IsFilterCompiled()
362+ done := s.compileDone
363+ s.mu.RUnlock()
364+ if !compiled {
365+ // Wait for current compile to finish, then try again (with timeout to avoid hangs)
366+ if done == nil {
367+ return
368+ }
369+ go func(ch <-chan struct{}, view *pkgwebkit.WebView) {
370+ select {
371+ case <-ch:
372+ // Re-attempt apply after compile completes
373+ if view != nil && !view.IsDestroyed() {
374+ s.applyFiltersToWebView(view)
375+ }
376+ case <-time.After(10 * time.Second):
377+ // Timed out waiting for compile; skip
378+ logging.Warn("[filtering] Timeout waiting for filter compilation before applying to WebView")
379+ }
380+ }(done, wv)
273 return381 return
274 }382 }
275 383
@@ -306,6 +414,11 @@ func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filt
306 return414 return
307 }415 }
308 416
417+ // Double-check compile state before proceeding
418+ if !s.contentBlockingMgr.IsFilterCompiled() {
419+ return
420+ }
421+
309 gtkView := wv.GetWebView()422 gtkView := wv.GetWebView()
310 if gtkView == nil {423 if gtkView == nil {
311 return424 return
@@ -327,6 +440,9 @@ func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filt
327 const identifier = "adblock-filters"440 const identifier = "adblock-filters"
328 441
329 // Try to apply cached compiled filter first (instant)442 // Try to apply cached compiled filter first (instant)
443+ s.applyMu.Lock()
444+ defer s.applyMu.Unlock()
445+
330 if s.contentBlockingMgr.IsFilterCompiled() {446 if s.contentBlockingMgr.IsFilterCompiled() {
331 if err := s.contentBlockingMgr.ApplyCompiledFilter(ucm); err != nil {447 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))448 logging.Error(fmt.Sprintf("[filtering] Failed to apply cached filter to WebView %d: %v", wv.ID(), err))
@@ -360,6 +476,8 @@ func (s *ContentBlockingService) setupCosmeticHooks(wv *pkgwebkit.WebView) {
360 }476 }
361 s.runWhenWidgetReady(webView, func() {477 s.runWhenWidgetReady(webView, func() {
362 s.injectCosmeticRules(webView, uri)478 s.injectCosmeticRules(webView, uri)
479+ // Apply network filters lazily on navigation when compilation is ready
480+ s.applyFiltersToWebView(webView)
363 })481 })
364 })482 })
365 }483 }

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