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: | |||
| 54 | 54 | --browser-cache, -b Purge WebKit browser cache (cached images, files, etc.) | |
| 55 | 55 | --browser-data, -B Purge WebKit browser data (localStorage, sessionStorage, IndexedDB) | |
| 56 | 56 | 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) | |
| 58 | 58 | --state, -s Purge all state data (includes databases and caches) | |
| 59 | 59 | --config, -c Purge configuration files | |
| 60 | 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 | 217 | if err != nil { | |
| 218 | 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 | 229 | case "config": | |
| 223 | 230 | 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 --database | 56 | 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) |
| 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 files | 59 | --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 { | |||
| 132 | 132 | }, | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | + | // Track if resource type was explicitly set | |
| 136 | + | hasExplicitResourceType := false | |
| 137 | + | ||
| 135 | 138 | // Parse options | |
| 136 | 139 | for _, opt := range options { | |
| 137 | 140 | opt = strings.TrimSpace(opt) | |
| @@ -160,18 +163,39 @@ func (fc *FilterConverter) parseNetworkFilter(line string) error { | |||
| 160 | 163 | } | |
| 161 | 164 | case opt == "script": | |
| 162 | 165 | rule.Trigger.ResourceType = []string{ResourceTypeScript} | |
| 166 | + | hasExplicitResourceType = true | |
| 163 | 167 | case opt == "image": | |
| 164 | 168 | rule.Trigger.ResourceType = []string{ResourceTypeImage} | |
| 169 | + | hasExplicitResourceType = true | |
| 165 | 170 | case opt == "stylesheet": | |
| 166 | 171 | rule.Trigger.ResourceType = []string{ResourceTypeStyleSheet} | |
| 172 | + | hasExplicitResourceType = true | |
| 167 | 173 | case opt == "font": | |
| 168 | 174 | rule.Trigger.ResourceType = []string{ResourceTypeFont} | |
| 175 | + | hasExplicitResourceType = true | |
| 169 | 176 | case opt == "media": | |
| 170 | 177 | rule.Trigger.ResourceType = []string{ResourceTypeMedia} | |
| 178 | + | hasExplicitResourceType = true | |
| 171 | 179 | case opt == "document": | |
| 172 | 180 | rule.Trigger.ResourceType = []string{ResourceTypeDocument} | |
| 181 | + | hasExplicitResourceType = true | |
| 173 | 182 | case opt == "popup": | |
| 174 | 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 | ||
| @@ -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 options | 138 | // 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 ( | |||
| 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 | |
| @@ -36,6 +38,9 @@ func NewCosmeticInjector() *CosmeticInjector { | |||
| 36 | 38 | ||
| 37 | 39 | // InjectRules stores cosmetic rules for domain-specific filtering | |
| 38 | 40 | func (ci *CosmeticInjector) InjectRules(rules map[string][]string) error { | |
| 41 | + | ci.mu.Lock() | |
| 42 | + | defer ci.mu.Unlock() | |
| 43 | + | ||
| 39 | 44 | if !ci.scriptEnabled { | |
| 40 | 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 | 69 | // GetScriptForDomain returns the complete cosmetic filtering script for a domain | |
| 65 | 70 | func (ci *CosmeticInjector) GetScriptForDomain(domain string) string { | |
| 71 | + | ci.mu.RLock() | |
| 72 | + | defer ci.mu.RUnlock() | |
| 73 | + | ||
| 66 | 74 | if !ci.scriptEnabled { | |
| 67 | 75 | return "" | |
| 68 | 76 | } | |
| @@ -130,6 +138,9 @@ func (ci *CosmeticInjector) GetScriptForDomain(domain string) string { | |||
| 130 | 138 | ||
| 131 | 139 | // UpdateRulesForDomain adds new rules for a specific domain | |
| 132 | 140 | func (ci *CosmeticInjector) UpdateRulesForDomain(domain string, newSelectors []string) { | |
| 141 | + | ci.mu.Lock() | |
| 142 | + | defer ci.mu.Unlock() | |
| 143 | + | ||
| 133 | 144 | if !ci.scriptEnabled { | |
| 134 | 145 | return | |
| 135 | 146 | } | |
| @@ -154,7 +165,11 @@ func (ci *CosmeticInjector) UpdateRulesForDomain(domain string, newSelectors []s | |||
| 154 | 165 | ||
| 155 | 166 | // GetUpdateScript returns JavaScript to update rules dynamically | |
| 156 | 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 | 173 | return "" | |
| 159 | 174 | } | |
| 160 | 175 | ||
| @@ -190,6 +205,9 @@ func (ci *CosmeticInjector) GetCleanupScript() string { | |||
| 190 | 205 | ||
| 191 | 206 | // GetRulesForDomain returns all cosmetic rules that apply to a domain | |
| 192 | 207 | func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string { | |
| 208 | + | ci.mu.RLock() | |
| 209 | + | defer ci.mu.RUnlock() | |
| 210 | + | ||
| 193 | 211 | var rules []string | |
| 194 | 212 | ||
| 195 | 213 | // Add generic rules | |
| @@ -215,7 +233,10 @@ func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string { | |||
| 215 | 233 | ||
| 216 | 234 | // Enable enables or disables cosmetic filtering | |
| 217 | 235 | func (ci *CosmeticInjector) Enable(enabled bool) { | |
| 236 | + | ci.mu.Lock() | |
| 218 | 237 | ci.scriptEnabled = enabled | |
| 238 | + | ci.mu.Unlock() | |
| 239 | + | ||
| 219 | 240 | if enabled { | |
| 220 | 241 | logging.Info("Cosmetic filtering enabled") | |
| 221 | 242 | } else { | |
| @@ -225,11 +246,16 @@ func (ci *CosmeticInjector) Enable(enabled bool) { | |||
| 225 | 246 | ||
| 226 | 247 | // IsEnabled returns whether cosmetic filtering is enabled | |
| 227 | 248 | func (ci *CosmeticInjector) IsEnabled() bool { | |
| 249 | + | ci.mu.RLock() | |
| 250 | + | defer ci.mu.RUnlock() | |
| 228 | 251 | return ci.scriptEnabled | |
| 229 | 252 | } | |
| 230 | 253 | ||
| 231 | 254 | // GetStats returns statistics about loaded cosmetic rules | |
| 232 | 255 | func (ci *CosmeticInjector) GetStats() map[string]interface{} { | |
| 256 | + | ci.mu.RLock() | |
| 257 | + | defer ci.mu.RUnlock() | |
| 258 | + | ||
| 233 | 259 | domainCount := len(ci.domainRules) | |
| 234 | 260 | totalRules := len(ci.genericRules) | |
| 235 | 261 | ||
| @@ -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 | ||
| @@ -36,6 +38,9 @@ func NewCosmeticInjector() *CosmeticInjector { | |||||
| 36 | 38 | ||||
| 37 | // InjectRules stores cosmetic rules for domain-specific filtering | 39 | // 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 domain | 69 | // 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 domain | 139 | // 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 | return | 145 | return | ||
| 135 | } | 146 | } | ||
| @@ -154,7 +165,11 @@ func (ci *CosmeticInjector) UpdateRulesForDomain(domain string, newSelectors []s | |||||
| 154 | 165 | ||||
| 155 | // GetUpdateScript returns JavaScript to update rules dynamically | 166 | // 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 domain | 206 | // 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 []string | 211 | var rules []string | ||
| 194 | 212 | ||||
| 195 | // Add generic rules | 213 | // Add generic rules | ||
| @@ -215,7 +233,10 @@ func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string { | |||||
| 215 | 233 | ||||
| 216 | // Enable enables or disables cosmetic filtering | 234 | // 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 = enabled | 237 | 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 enabled | 247 | // 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.scriptEnabled | 251 | return ci.scriptEnabled | ||
| 229 | } | 252 | } | ||
| 230 | 253 | ||||
| 231 | // GetStats returns statistics about loaded cosmetic rules | 254 | // 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) { | |||
| 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) | |
| 300 | + | // Apply incrementally removed to prevent excessive recompilation/churn | |
| 301 | + | // logging.Info(fmt.Sprintf("[filtering] Partial merge: %d network rules", len(merged.NetworkRules))) | |
| 303 | 302 | } | |
| 304 | 303 | } | |
| 305 | 304 | ||
| @@ -307,6 +306,9 @@ func (fm *FilterManager) compileFromSources(ctx context.Context) { | |||
| 307 | 306 | if hasContent { | |
| 308 | 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 | 312 | // Cache the final result | |
| 311 | 313 | if err := fm.store.SaveCache(merged); err != nil { | |
| 312 | 314 | logging.Error(fmt.Sprintf("Failed to save filter cache: %v", err)) | |
| @@ -412,40 +414,6 @@ func (fm *FilterManager) applyFilters(filters *CompiledFilters) { | |||
| 412 | 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 | 417 | // Add internal scheme exceptions LAST to ensure they override all blocking rules | |
| 450 | 418 | // The dumb:// scheme is used for internal pages (homepage, blocked page, etc.) | |
| 451 | 419 | // 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 = true | 298 | hasContent = true | ||
| 299 | merged.Merge(compiled) | 299 | merged.Merge(compiled) | ||
| 300 | - | // Apply incrementally for faster initial blocking | 300 | + | // 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 result | 312 | // 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 rules | 417 | // 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 filtering | 419 | // and must never be blocked by content filtering | ||
M internal/filtering/service.go +96 -277
| @@ -4,87 +4,33 @@ package filtering | |||
| 4 | 4 | import ( | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "net/url" | |
| 7 | + | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | ||
| 9 | 10 | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | |
| 10 | - | glib "github.com/diamondburned/gotk4/pkg/glib/v2" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/dumber/internal/filtering/cosmetic" | |
| 13 | 13 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 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. | |
| 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:"} | |
| 61 | 19 | ||
| 62 | 20 | // 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 | |
| 65 | 25 | type ContentBlockingService struct { | |
| 66 | 26 | mu sync.RWMutex | |
| 67 | 27 | ||
| 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 | |
| 78 | 30 | 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 | |
| 88 | 34 | } | |
| 89 | 35 | ||
| 90 | 36 | // NewContentBlockingService creates a new content blocking service. | |
| @@ -98,210 +44,114 @@ func NewContentBlockingService(dataDir string, filterManager *FilterManager) (*C | |||
| 98 | 44 | return nil, fmt.Errorf("failed to create content blocking manager: %w", err) | |
| 99 | 45 | } | |
| 100 | 46 | ||
| 101 | - | service := &ContentBlockingService{ | |
| 47 | + | return &ContentBlockingService{ | |
| 102 | 48 | filterManager: filterManager, | |
| 103 | 49 | cosmeticInjector: filterManager.cosmeticInjector, | |
| 104 | - | webViews: make(map[*pkgwebkit.WebView]webViewState), | |
| 105 | 50 | contentBlockingMgr: cbm, | |
| 106 | 51 | dataDir: dataDir, | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | return service, nil | |
| 52 | + | }, nil | |
| 110 | 53 | } | |
| 111 | 54 | ||
| 112 | 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 | 57 | func (s *ContentBlockingService) SetFiltersReady(networkJSON []byte) { | |
| 115 | 58 | s.mu.Lock() | |
| 116 | 59 | 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 | |
| 122 | 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 | 63 | if len(networkJSON) == 0 { | |
| 138 | 64 | logging.Warn("[filtering] SetFiltersReady called with empty network filter JSON") | |
| 139 | 65 | return | |
| 140 | 66 | } | |
| 141 | 67 | ||
| 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) | |
| 144 | 70 | s.contentBlockingMgr.CompileFilters("adblock-filters", networkJSON, func(err error) { | |
| 145 | 71 | if err != nil { | |
| 146 | 72 | logging.Error(fmt.Sprintf("[filtering] Filter compilation failed: %v", err)) | |
| 147 | 73 | return | |
| 148 | 74 | } | |
| 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") | |
| 167 | 76 | }) | |
| 168 | 77 | } | |
| 169 | 78 | ||
| 170 | 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 | 82 | func (s *ContentBlockingService) RegisterWebView(wv *pkgwebkit.WebView) { | |
| 173 | 83 | if wv == nil { | |
| 174 | 84 | 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 | 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) | |
| 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() | |
| 190 | 96 | ||
| 191 | - | // Only inject cosmetic base script if filters are loaded (rules available) | |
| 192 | - | // Otherwise, it will be injected when SetFiltersReady completes | |
| 193 | 97 | if filtersReady { | |
| 194 | 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 | 102 | if s.contentBlockingMgr.IsFilterCompiled() { | |
| 199 | - | webView := wv // capture for closure | |
| 200 | - | s.runWhenWidgetReady(webView, func() { | |
| 201 | - | s.applyFiltersToWebView(webView) | |
| 202 | - | }) | |
| 103 | + | s.applyNetworkFilter(wv) | |
| 203 | 104 | } | |
| 204 | 105 | } | |
| 205 | 106 | ||
| 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 | |
| 252 | 111 | } | |
| 253 | 112 | ||
| 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) { | |
| 256 | 115 | if wv == nil { | |
| 257 | 116 | return | |
| 258 | 117 | } | |
| 259 | 118 | ||
| 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 | + | }) | |
| 263 | 126 | } | |
| 264 | 127 | ||
| 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) { | |
| 267 | 131 | if wv == nil || wv.IsDestroyed() { | |
| 268 | 132 | return | |
| 269 | 133 | } | |
| 270 | 134 | ||
| 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 { | |
| 273 | 137 | return | |
| 274 | 138 | } | |
| 275 | 139 | ||
| 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 { | |
| 280 | 142 | 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 { | |
| 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)) | |
| 294 | 147 | return | |
| 295 | 148 | } | |
| 296 | 149 | ||
| 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())) | |
| 301 | 151 | } | |
| 302 | 152 | ||
| 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) { | |
| 305 | 155 | if wv == nil || wv.IsDestroyed() { | |
| 306 | 156 | return | |
| 307 | 157 | } | |
| @@ -313,64 +163,55 @@ func (s *ContentBlockingService) applyNetworkFilters(wv *pkgwebkit.WebView, filt | |||
| 313 | 163 | ||
| 314 | 164 | ucm := gtkView.UserContentManager() | |
| 315 | 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 | 166 | return | |
| 324 | 167 | } | |
| 325 | 168 | ||
| 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() { | |
| 343 | 171 | return | |
| 344 | 172 | } | |
| 345 | 173 | ||
| 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 == "" { | |
| 352 | 177 | return | |
| 353 | 178 | } | |
| 354 | 179 | ||
| 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 | |
| 360 | 198 | } | |
| 361 | - | s.runWhenWidgetReady(webView, func() { | |
| 362 | - | s.injectCosmeticRules(webView, uri) | |
| 363 | - | }) | |
| 364 | - | }) | |
| 199 | + | } | |
| 200 | + | return false | |
| 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 | 205 | func (s *ContentBlockingService) injectCosmeticRules(wv *pkgwebkit.WebView, uri string) { | |
| 369 | 206 | if wv == nil || wv.IsDestroyed() || uri == "" { | |
| 370 | 207 | return | |
| 371 | 208 | } | |
| 372 | 209 | ||
| 373 | - | // Parse domain from URI | |
| 210 | + | // Skip local/internal schemes | |
| 211 | + | if isLocalScheme(uri) { | |
| 212 | + | return | |
| 213 | + | } | |
| 214 | + | ||
| 374 | 215 | parsedURL, err := url.Parse(uri) | |
| 375 | 216 | if err != nil || parsedURL.Host == "" { | |
| 376 | 217 | return | |
| @@ -378,11 +219,7 @@ func (s *ContentBlockingService) injectCosmeticRules(wv *pkgwebkit.WebView, uri | |||
| 378 | 219 | ||
| 379 | 220 | domain := parsedURL.Hostname() | |
| 380 | 221 | ||
| 381 | - | // Get cosmetic script for this domain | |
| 382 | - | s.mu.RLock() | |
| 383 | 222 | injector := s.cosmeticInjector | |
| 384 | - | s.mu.RUnlock() | |
| 385 | - | ||
| 386 | 223 | if injector == nil || !injector.IsEnabled() { | |
| 387 | 224 | return | |
| 388 | 225 | } | |
| @@ -392,36 +229,27 @@ func (s *ContentBlockingService) injectCosmeticRules(wv *pkgwebkit.WebView, uri | |||
| 392 | 229 | return | |
| 393 | 230 | } | |
| 394 | 231 | ||
| 395 | - | // Inject the script | |
| 396 | 232 | 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 | + | } | |
| 408 | 240 | } | |
| 409 | 241 | ||
| 410 | 242 | // GetFilterManager returns the underlying filter manager. | |
| 411 | 243 | func (s *ContentBlockingService) GetFilterManager() *FilterManager { | |
| 412 | - | s.mu.RLock() | |
| 413 | - | defer s.mu.RUnlock() | |
| 414 | 244 | return s.filterManager | |
| 415 | 245 | } | |
| 416 | 246 | ||
| 417 | 247 | // GetCosmeticInjector returns the cosmetic injector. | |
| 418 | 248 | func (s *ContentBlockingService) GetCosmeticInjector() *cosmetic.CosmeticInjector { | |
| 419 | - | s.mu.RLock() | |
| 420 | - | defer s.mu.RUnlock() | |
| 421 | 249 | 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 | 253 | func (s *ContentBlockingService) IsReady() bool { | |
| 426 | 254 | s.mu.RLock() | |
| 427 | 255 | defer s.mu.RUnlock() | |
| @@ -433,18 +261,9 @@ func (s *ContentBlockingService) GetStats() map[string]interface{} { | |||
| 433 | 261 | s.mu.RLock() | |
| 434 | 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 | 264 | stats := map[string]interface{}{ | |
| 445 | 265 | "ready": s.filtersReady, | |
| 446 | - | "webview_count": webViewCount, | |
| 447 | - | "filters_applied": appliedCount, | |
| 266 | + | "filter_compiled": s.contentBlockingMgr.IsFilterCompiled(), | |
| 448 | 267 | "network_rules_bytes": len(s.networkFilterJSON), | |
| 449 | 268 | } | |
| 450 | 269 | ||
| @@ -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 consistently | 21 | + | // 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.RWMutex | 26 | mu sync.RWMutex | ||
| 67 | 27 | ||||
| 68 | - | // Filter data | 28 | + | filterManager *FilterManager |
| 69 | - | networkFilterJSON []byte | 29 | + | 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.ContentBlockingManager | 30 | contentBlockingMgr *pkgwebkit.ContentBlockingManager | ||
| 79 | - | 31 | + | networkFilterJSON []byte | |
| 80 | - | // Data directory for filter storage | 32 | + | filtersReady bool |
| 81 | - | dataDir string | 33 | + | 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 = networkJSON | 59 | s.networkFilterJSON = networkJSON | ||
| 117 | - | s.filtersReady = true // Cosmetic rules are ready immediately | 60 | + | 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 | return | 65 | return | ||
| 140 | } | 66 | } | ||
| 141 | 67 | ||||
| 142 | - | // Compile network filters asynchronously - this happens in the background | 68 | + | // 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 | return | 73 | 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 | return | 84 | 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 closure | 103 | + | 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 | return | 116 | 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 | return | 132 | 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 | return | 137 | 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 | return | 142 | 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 flag | 146 | + | 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 | return | 147 | return | ||
| 295 | } | 148 | } | ||
| 296 | 149 | ||||
| 297 | - | webView := wv // capture for closure | 150 | + | 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 | return | 156 | 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 | return | 166 | return | ||
| 324 | } | 167 | } | ||
| 325 | 168 | ||||
| 326 | - | // Use shared identifier so all WebViews reuse the same compiled filter | 169 | + | 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 | return | 171 | 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 | return | 177 | return | ||
| 353 | } | 178 | } | ||
| 354 | 179 | ||||
| 355 | - | // Register load-committed handler to inject domain-specific cosmetic rules | 180 | + | // Inject at document-end when DOM is ready |
| 356 | - | webView := wv // capture for closure | 181 | + | userScript := webkit.NewUserScript( |
| 357 | - | wv.RegisterLoadCommittedHandler(func(uri string) { | 182 | + | baseScript, |
| 358 | - | if webView.IsDestroyed() { | 183 | + | webkit.UserContentInjectAllFrames, |
| 359 | - | return | 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 | |||
| 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 | return | 207 | return | ||
| 371 | } | 208 | } | ||
| 372 | 209 | ||||
| 373 | - | // Parse domain from URI | 210 | + | // 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 | return | 217 | 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.cosmeticInjector | 222 | injector := s.cosmeticInjector | ||
| 384 | - | s.mu.RUnlock() | |||
| 385 | - | ||||
| 386 | if injector == nil || !injector.IsEnabled() { | 223 | if injector == nil || !injector.IsEnabled() { | ||
| 387 | return | 224 | return | ||
| 388 | } | 225 | } | ||
| @@ -392,36 +229,27 @@ func (s *ContentBlockingService) injectCosmeticRules(wv *pkgwebkit.WebView, uri | |||||
| 392 | return | 229 | 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 closure | 233 | + | |
| 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 | - | return | 236 | + | 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.filterManager | 244 | 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.cosmeticInjector | 249 | 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 { | |||
| 110 | 110 | return fmt.Errorf("failed to serialize filters: %w", err) | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 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 | |
| 114 | 125 | 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, | |
| 121 | 133 | } | |
| 122 | 134 | ||
| 123 | 135 | // 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 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 | |||
| 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 implementation | 130 | + | FilterHashes: []string{}, |
| 120 | - | FilterHashes: []string{}, | 131 | + | SourceVersions: sourceVersions, |
| 132 | + | LastCheckTime: lastCheckTime, | |||
| 121 | } | 133 | } | ||
| 122 | 134 | ||||
| 123 | // Write cache data atomically | 135 | // Write cache data atomically | ||
M pkg/webkit/content_blocking.go +52 -4
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "path/filepath" | |
| 7 | + | "runtime" | |
| 7 | 8 | "sync" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/logging" | |
| @@ -21,6 +22,7 @@ type ContentBlockingManager struct { | |||
| 21 | 22 | mu sync.RWMutex | |
| 22 | 23 | compiledFilter *webkit.UserContentFilter | |
| 23 | 24 | filterID string | |
| 25 | + | contentHash string | |
| 24 | 26 | } | |
| 25 | 27 | ||
| 26 | 28 | // NewContentBlockingManager creates a new content blocking manager | |
| @@ -56,6 +58,30 @@ func (cbm *ContentBlockingManager) CompileFilters(identifier string, jsonRules [ | |||
| 56 | 58 | cbm.filterID = identifier | |
| 57 | 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 | 85 | // Convert []byte to *glib.Bytes for WebKit API | |
| 60 | 86 | gBytes := glib.NewBytesWithGo(jsonRules) | |
| 61 | 87 | ||
| @@ -85,15 +111,22 @@ func (cbm *ContentBlockingManager) CompileFilters(identifier string, jsonRules [ | |||
| 85 | 111 | cbm.compiledFilter = filter | |
| 86 | 112 | cbm.mu.Unlock() | |
| 87 | 113 | ||
| 114 | + | // Prevent GC from collecting filter prematurely (Go wrapper) | |
| 115 | + | runtime.KeepAlive(filter) | |
| 116 | + | ||
| 88 | 117 | 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 | + | }) | |
| 92 | 125 | }) | |
| 93 | 126 | } | |
| 94 | 127 | ||
| 95 | 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 | 130 | func (cbm *ContentBlockingManager) ApplyCompiledFilter(ucm *webkit.UserContentManager) error { | |
| 98 | 131 | if ucm == nil { | |
| 99 | 132 | return fmt.Errorf("UserContentManager is nil") | |
| @@ -107,7 +140,13 @@ func (cbm *ContentBlockingManager) ApplyCompiledFilter(ucm *webkit.UserContentMa | |||
| 107 | 140 | return fmt.Errorf("no compiled filter available") | |
| 108 | 141 | } | |
| 109 | 142 | ||
| 143 | + | // Apply the cached filter directly - UCM keeps its own reference | |
| 110 | 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 | 150 | return nil | |
| 112 | 151 | } | |
| 113 | 152 | ||
| @@ -167,10 +206,19 @@ func (cbm *ContentBlockingManager) ApplyFiltersFromJSON(ucm *webkit.UserContentM | |||
| 167 | 206 | ||
| 168 | 207 | // Cache for future use (only valid filters) | |
| 169 | 208 | cbm.mu.Lock() | |
| 209 | + | oldFilter := cbm.compiledFilter | |
| 170 | 210 | cbm.compiledFilter = filter | |
| 171 | 211 | cbm.filterID = identifier | |
| 172 | 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 | 222 | RunOnMainThread(func() { | |
| 175 | 223 | if filter != nil { | |
| 176 | 224 | 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.RWMutex | 22 | mu sync.RWMutex | ||
| 22 | compiledFilter *webkit.UserContentFilter | 23 | compiledFilter *webkit.UserContentFilter | ||
| 23 | filterID string | 24 | filterID string | ||
| 25 | + | contentHash string | |||
| 24 | } | 26 | } | ||
| 25 | 27 | ||||
| 26 | // NewContentBlockingManager creates a new content blocking manager | 28 | // NewContentBlockingManager creates a new content blocking manager | ||
| @@ -56,6 +58,30 @@ func (cbm *ContentBlockingManager) CompileFilters(identifier string, jsonRules [ | |||||
| 56 | cbm.filterID = identifier | 58 | 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 API | 85 | // 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 = filter | 111 | 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 nil | 150 | 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 = filter | 210 | cbm.compiledFilter = filter | ||
| 171 | cbm.filterID = identifier | 211 | 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 | + | } | |||