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 ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "strings" | |
| 8 | + | "sync" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 11 | ) | |
| @@ -14,6 +15,7 @@ var cosmeticScript string | |||
| 14 | 15 | ||
| 15 | 16 | // CosmeticInjector manages cosmetic filter injection | |
| 16 | 17 | type CosmeticInjector struct { | |
| 18 | + | mu sync.RWMutex | |
| 17 | 19 | domainRules map[string][]string | |
| 18 | 20 | genericRules []string | |
| 19 | 21 | scriptEnabled bool | |
| @@ -40,6 +42,9 @@ func (ci *CosmeticInjector) InjectRules(rules map[string][]string) error { | |||
| 40 | 42 | return fmt.Errorf("cosmetic filtering disabled") | |
| 41 | 43 | } | |
| 42 | 44 | ||
| 45 | + | ci.mu.Lock() | |
| 46 | + | defer ci.mu.Unlock() | |
| 47 | + | ||
| 43 | 48 | // Clear existing rules | |
| 44 | 49 | ci.domainRules = make(map[string][]string) | |
| 45 | 50 | ci.genericRules = make([]string, 0) | |
| @@ -67,6 +72,9 @@ func (ci *CosmeticInjector) GetScriptForDomain(domain string) string { | |||
| 67 | 72 | return "" | |
| 68 | 73 | } | |
| 69 | 74 | ||
| 75 | + | ci.mu.RLock() | |
| 76 | + | defer ci.mu.RUnlock() | |
| 77 | + | ||
| 70 | 78 | // Build rules for this specific domain (including parent domains) | |
| 71 | 79 | var rules []CosmeticRule | |
| 72 | 80 | ||
| @@ -134,6 +142,9 @@ func (ci *CosmeticInjector) UpdateRulesForDomain(domain string, newSelectors []s | |||
| 134 | 142 | return | |
| 135 | 143 | } | |
| 136 | 144 | ||
| 145 | + | ci.mu.Lock() | |
| 146 | + | defer ci.mu.Unlock() | |
| 147 | + | ||
| 137 | 148 | if len(newSelectors) == 0 { | |
| 138 | 149 | return | |
| 139 | 150 | } | |
| @@ -158,6 +169,9 @@ func (ci *CosmeticInjector) GetUpdateScript(newSelectors []string) string { | |||
| 158 | 169 | return "" | |
| 159 | 170 | } | |
| 160 | 171 | ||
| 172 | + | ci.mu.RLock() | |
| 173 | + | defer ci.mu.RUnlock() | |
| 174 | + | ||
| 161 | 175 | selectorsJSON, err := json.Marshal(newSelectors) | |
| 162 | 176 | if err != nil { | |
| 163 | 177 | logging.Error(fmt.Sprintf("Failed to marshal update selectors: %v", err)) | |
| @@ -190,6 +204,9 @@ func (ci *CosmeticInjector) GetCleanupScript() string { | |||
| 190 | 204 | ||
| 191 | 205 | // GetRulesForDomain returns all cosmetic rules that apply to a domain | |
| 192 | 206 | func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string { | |
| 207 | + | ci.mu.RLock() | |
| 208 | + | defer ci.mu.RUnlock() | |
| 209 | + | ||
| 193 | 210 | var rules []string | |
| 194 | 211 | ||
| 195 | 212 | // Add generic rules | |
| @@ -215,6 +232,9 @@ func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string { | |||
| 215 | 232 | ||
| 216 | 233 | // Enable enables or disables cosmetic filtering | |
| 217 | 234 | func (ci *CosmeticInjector) Enable(enabled bool) { | |
| 235 | + | ci.mu.Lock() | |
| 236 | + | defer ci.mu.Unlock() | |
| 237 | + | ||
| 218 | 238 | ci.scriptEnabled = enabled | |
| 219 | 239 | if enabled { | |
| 220 | 240 | logging.Info("Cosmetic filtering enabled") | |
| @@ -225,11 +245,15 @@ func (ci *CosmeticInjector) Enable(enabled bool) { | |||
| 225 | 245 | ||
| 226 | 246 | // IsEnabled returns whether cosmetic filtering is enabled | |
| 227 | 247 | func (ci *CosmeticInjector) IsEnabled() bool { | |
| 248 | + | ci.mu.RLock() | |
| 249 | + | defer ci.mu.RUnlock() | |
| 228 | 250 | return ci.scriptEnabled | |
| 229 | 251 | } | |
| 230 | 252 | ||
| 231 | 253 | // GetStats returns statistics about loaded cosmetic rules | |
| 232 | 254 | func (ci *CosmeticInjector) GetStats() map[string]interface{} { | |
| 255 | + | ci.mu.RLock() | |
| 256 | + | defer ci.mu.RUnlock() | |
| 233 | 257 | domainCount := len(ci.domainRules) | |
| 234 | 258 | totalRules := len(ci.genericRules) | |
| 235 | 259 | ||
| @@ -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 injection | 16 | // CosmeticInjector manages cosmetic filter injection | ||
| 16 | type CosmeticInjector struct { | 17 | type CosmeticInjector struct { | ||
| 18 | + | mu sync.RWMutex | |||
| 17 | domainRules map[string][]string | 19 | domainRules map[string][]string | ||
| 18 | genericRules []string | 20 | genericRules []string | ||
| 19 | scriptEnabled bool | 21 | 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 rules | 48 | // 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 []CosmeticRule | 79 | var rules []CosmeticRule | ||
| 72 | 80 | ||||
| @@ -134,6 +142,9 @@ func (ci *CosmeticInjector) UpdateRulesForDomain(domain string, newSelectors []s | |||||
| 134 | return | 142 | 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 | return | 149 | 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 domain | 205 | // 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 []string | 210 | var rules []string | ||
| 194 | 211 | ||||
| 195 | // Add generic rules | 212 | // Add generic rules | ||
| @@ -215,6 +232,9 @@ func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string { | |||||
| 215 | 232 | ||||
| 216 | // Enable enables or disables cosmetic filtering | 233 | // 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 = enabled | 238 | 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 enabled | 246 | // 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.scriptEnabled | 250 | return ci.scriptEnabled | ||
| 229 | } | 251 | } | ||
| 230 | 252 | ||||
| 231 | // GetStats returns statistics about loaded cosmetic rules | 253 | // 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) { | |||
| 297 | 297 | if compiled != nil && len(compiled.NetworkRules) > 0 { | |
| 298 | 298 | hasContent = true | |
| 299 | 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 | 304 | if hasContent { | |
| 308 | 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 | 310 | // Cache the final result | |
| 311 | 311 | if err := fm.store.SaveCache(merged); err != nil { | |
| 312 | 312 | logging.Error(fmt.Sprintf("Failed to save filter cache: %v", err)) | |
| @@ -405,7 +405,7 @@ func (fm *FilterManager) applyFilters(filters *CompiledFilters) { | |||
| 405 | 405 | logging.Info("[filtering] Content filtering is DISABLED in config - no ad blocking active") | |
| 406 | 406 | return | |
| 407 | 407 | } | |
| 408 | - | ||
| 408 | + | // Re-enable database-driven whitelist injection (ignore-previous-rules) now that filtering apply is stabilized. | |
| 409 | 409 | fm.updateWhitelistCache() | |
| 410 | 410 | if len(fm.cachedWhitelistRules) > 0 { | |
| 411 | 411 | 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 = true | 298 | 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 result | 310 | // 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 | return | 406 | 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 @@ | |||
| 2 | 2 | package filtering | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "crypto/sha256" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "net/url" | |
| 7 | 8 | "sync" | |
| 9 | + | "time" | |
| 8 | 10 | ||
| 9 | 11 | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | |
| 10 | 12 | glib "github.com/diamondburned/gotk4/pkg/glib/v2" | |
| @@ -67,6 +69,9 @@ type ContentBlockingService struct { | |||
| 67 | 69 | ||
| 68 | 70 | // Filter data | |
| 69 | 71 | 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 | 75 | filterManager *FilterManager | |
| 71 | 76 | cosmeticInjector *cosmetic.CosmeticInjector | |
| 72 | 77 | filtersReady bool | |
| @@ -74,6 +79,9 @@ type ContentBlockingService struct { | |||
| 74 | 79 | // WebView registry - tracks all active WebViews | |
| 75 | 80 | 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 | 85 | // Content blocking manager for compiling filters | |
| 78 | 86 | contentBlockingMgr *pkgwebkit.ContentBlockingManager | |
| 79 | 87 | ||
| @@ -112,13 +120,96 @@ func NewContentBlockingService(dataDir string, filterManager *FilterManager) (*C | |||
| 112 | 120 | // SetFiltersReady is called when filters have been parsed and are ready to compile. | |
| 113 | 121 | // It compiles filters asynchronously, then applies to all registered WebViews. | |
| 114 | 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 | 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 | 189 | s.networkFilterJSON = networkJSON | |
| 117 | - | s.filtersReady = true // Cosmetic rules are ready immediately | |
| 190 | + | s.networkHash = hash | |
| 191 | + | s.filtersReady = true | |
| 118 | 192 | webViews := make([]*pkgwebkit.WebView, 0, len(s.webViews)) | |
| 119 | 193 | for wv := range s.webViews { | |
| 120 | 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 | 213 | s.mu.Unlock() | |
| 123 | 214 | ||
| 124 | 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 | 233 | // Compile network filters asynchronously - this happens in the background | |
| 143 | 234 | // 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 | + | } | |
| 159 | 249 | ||
| 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 | + | }) | |
| 167 | 255 | }) | |
| 168 | 256 | } | |
| 169 | 257 | ||
| @@ -269,7 +357,27 @@ func (s *ContentBlockingService) applyFiltersToWebView(wv *pkgwebkit.WebView) { | |||
| 269 | 357 | } | |
| 270 | 358 | ||
| 271 | 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 | 381 | return | |
| 274 | 382 | } | |
| 275 | 383 | ||
| @@ -306,6 +414,11 @@ func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filt | |||
| 306 | 414 | return | |
| 307 | 415 | } | |
| 308 | 416 | ||
| 417 | + | // Double-check compile state before proceeding | |
| 418 | + | if !s.contentBlockingMgr.IsFilterCompiled() { | |
| 419 | + | return | |
| 420 | + | } | |
| 421 | + | ||
| 309 | 422 | gtkView := wv.GetWebView() | |
| 310 | 423 | if gtkView == nil { | |
| 311 | 424 | return | |
| @@ -327,6 +440,9 @@ func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filt | |||
| 327 | 440 | const identifier = "adblock-filters" | |
| 328 | 441 | ||
| 329 | 442 | // Try to apply cached compiled filter first (instant) | |
| 443 | + | s.applyMu.Lock() | |
| 444 | + | defer s.applyMu.Unlock() | |
| 445 | + | ||
| 330 | 446 | if s.contentBlockingMgr.IsFilterCompiled() { | |
| 331 | 447 | if err := s.contentBlockingMgr.ApplyCompiledFilter(ucm); err != nil { | |
| 332 | 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 | 477 | s.runWhenWidgetReady(webView, func() { | |
| 362 | 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 | } | |
| @@ -2,9 +2,11 @@ | |||||
| 2 | package filtering | 2 | 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 data | 70 | // Filter data | ||
| 69 | networkFilterJSON []byte | 71 | 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 *FilterManager | 75 | filterManager *FilterManager | ||
| 71 | cosmeticInjector *cosmetic.CosmeticInjector | 76 | cosmeticInjector *cosmetic.CosmeticInjector | ||
| 72 | filtersReady bool | 77 | filtersReady bool | ||
| @@ -74,6 +79,9 @@ type ContentBlockingService struct { | |||||
| 74 | // WebView registry - tracks all active WebViews | 79 | // WebView registry - tracks all active WebViews | ||
| 75 | webViews map[*pkgwebkit.WebView]webViewState | 80 | 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 filters | 85 | // Content blocking manager for compiling filters | ||
| 78 | contentBlockingMgr *pkgwebkit.ContentBlockingManager | 86 | 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 = networkJSON | 189 | s.networkFilterJSON = networkJSON | ||
| 117 | - | s.filtersReady = true // Cosmetic rules are ready immediately | 190 | + | 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 background | 233 | // 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 | - | return | 238 | + | 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 WebViews | 250 | + | logging.Info("[filtering] Network filter compilation complete") |
| 161 | - | for _, wv := range currentWebViews { | 251 | + | if done != nil { |
| 162 | - | webView := wv // capture for closure | 252 | + | 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 | return | 381 | return | ||
| 274 | } | 382 | } | ||
| 275 | 383 | ||||
| @@ -306,6 +414,11 @@ func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filt | |||||
| 306 | return | 414 | 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 | return | 424 | 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 | } | ||