detached 8b7af10

feat(webkit): improve content filtering stability and add debug support

- Fix script injection timing to prevent WebKit preconnect interference
- Add comprehensive WebKit debug system with configurable categories
- Implement robust error handling with retry mechanisms and graceful fallbacks
- Optimize anti-breakage scriptlets to avoid WebKit internal conflicts
- Add parallel compilation support for faster builds with multi-core GCC
- Enhance filter loading timing to reduce first-load WebKit errors
commit
8b7af108f15bb1503d66406748030785c432dc11
author
bamen · · 2025-09-14 05:50 UTC
parent
b5c9cc5
signature
unsigned
29 files · +3299 -25
M CHANGELOG.md +10 -0
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **Content blocking system**: Native WebKit ad blocking with EasyList integration, cosmetic filtering, and filter cache management
9+- **Filter cache purging**: New `--filter-cache/-F` flag in purge command
10+- **WebKit debug system**: Comprehensive debugging support with configurable categories for troubleshooting WebKit internal errors
11+- **Enhanced error handling**: Robust content filter injection with retry mechanisms and graceful fallbacks
12+- **Parallel compilation**: Multi-core GCC compilation support for faster builds
13+
14+### Improved
15+- **Content filter timing**: Optimized script injection timing to prevent interference with WebKit preconnect operations
16+- **Anti-breakage scriptlets**: Safer JavaScript modifications that avoid WebKit internal conflicts
17+- **Build system**: Added parallel compilation with automatic core detection and success indicators
818 - **Persistent TLS certificate validation system**:
919 - **Three-option certificate dialog**: "Go Back", "Proceed Once (Unsafe)", and "Always Accept This Site"
1020 - **Database-backed certificate storage**: SQLite-based persistence for user certificate decisions with SHA256 certificate hashing
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **Content blocking system**: Native WebKit ad blocking with EasyList integration, cosmetic filtering, and filter cache management
9+- **Filter cache purging**: New `--filter-cache/-F` flag in purge command
10+- **WebKit debug system**: Comprehensive debugging support with configurable categories for troubleshooting WebKit internal errors
11+- **Enhanced error handling**: Robust content filter injection with retry mechanisms and graceful fallbacks
12+- **Parallel compilation**: Multi-core GCC compilation support for faster builds
13+
14+### Improved
15+- **Content filter timing**: Optimized script injection timing to prevent interference with WebKit preconnect operations
16+- **Anti-breakage scriptlets**: Safer JavaScript modifications that avoid WebKit internal conflicts
17+- **Build system**: Added parallel compilation with automatic core detection and success indicators
8 - **Persistent TLS certificate validation system**:18 - **Persistent TLS certificate validation system**:
9 - **Three-option certificate dialog**: "Go Back", "Proceed Once (Unsafe)", and "Always Accept This Site" 19 - **Three-option certificate dialog**: "Go Back", "Proceed Once (Unsafe)", and "Always Accept This Site"
10 - **Database-backed certificate storage**: SQLite-based persistence for user certificate decisions with SHA256 certificate hashing20 - **Database-backed certificate storage**: SQLite-based persistence for user certificate decisions with SHA256 certificate hashing
M Makefile +11 -6
@@ -13,6 +13,9 @@ BINARY_NAME=dumber
1313 MAIN_PATH=.
1414 DIST_DIR=dist
1515
16+# Detect number of CPU cores for parallel compilation
17+NPROCS?=$(shell nproc 2>/dev/null || echo 1)
18+
1619 # Local caches to avoid $HOME permission issues (override via .env.local)
1720 GOMODCACHE?=$(CURDIR)/tmp/go-mod
1821 GOCACHE?=$(CURDIR)/tmp/go-cache
@@ -34,9 +37,10 @@ help: ## Show this help message
3437
3538 # Build targets
3639 build: build-frontend ## Build the application with GUI (frontend assets, then Go binary with WebKitGTK)
37- @echo "Building $(BINARY_NAME) $(VERSION) with GUI..."
40+ @echo "Building $(BINARY_NAME) $(VERSION) with GUI using $(NPROCS) cores..."
3841 @mkdir -p $(DIST_DIR) tmp tmp/go-cache tmp/go-mod
39- $(GOENV) CGO_ENABLED=1 go build $(LDFLAGS) -tags=webkit_cgo -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
42+ $(GOENV) CGO_ENABLED=1 go build -p $(NPROCS) $(LDFLAGS) -tags=webkit_cgo -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
43+ @echo "✅ Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
4044
4145 build-frontend: ## Build TypeScript frontend
4246 @echo "Building TypeScript frontend..."
@@ -44,9 +48,10 @@ build-frontend: ## Build TypeScript frontend
4448 @echo "Frontend build complete"
4549
4650 build-no-gui: build-frontend ## Build binary without GUI (CGO disabled, CLI-only functionality)
47- @echo "Building $(BINARY_NAME) $(VERSION) (CLI-only, no GUI)..."
51+ @echo "Building $(BINARY_NAME) $(VERSION) (CLI-only, no GUI) using $(NPROCS) cores..."
4852 @mkdir -p $(DIST_DIR) tmp tmp/go-cache tmp/go-mod
49- $(GOENV) CGO_ENABLED=0 go build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-no-gui $(MAIN_PATH)
53+ $(GOENV) CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-no-gui $(MAIN_PATH)
54+ @echo "✅ Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)-no-gui"
5055
5156 build-static: build-no-gui ## Alias for build-no-gui (backward compatibility)
5257 @echo "Note: build-static is deprecated, use build-no-gui instead"
@@ -57,9 +62,9 @@ build-gui: build ## Alias for default build (backward compatibility)
5762 @echo "Note: build-gui is now the default 'build' target"
5863
5964 run-gui: ## Run the GUI with native WebKitGTK 6.0 (requires GTK4/WebKitGTK 6 dev packages)
60- @echo "Running GUI (webkit_cgo)…"
65+ @echo "Running GUI (webkit_cgo) using $(NPROCS) cores…"
6166 @mkdir -p tmp tmp/go-cache tmp/go-mod
62- $(GOENV) CGO_ENABLED=1 go run -tags=webkit_cgo $(MAIN_PATH)
67+ $(GOENV) CGO_ENABLED=1 go run -p $(NPROCS) -tags=webkit_cgo $(MAIN_PATH)
6368
6469 .PHONY: check-webkit
6570 check-webkit: ## Check system has GTK4/WebKitGTK 6.0/JavaScriptCore 6.0
@@ -13,6 +13,9 @@ BINARY_NAME=dumber
13 MAIN_PATH=.13 MAIN_PATH=.
14 DIST_DIR=dist14 DIST_DIR=dist
15 15
16+# Detect number of CPU cores for parallel compilation
17+NPROCS?=$(shell nproc 2>/dev/null || echo 1)
18+
16 # Local caches to avoid $HOME permission issues (override via .env.local)19 # Local caches to avoid $HOME permission issues (override via .env.local)
17 GOMODCACHE?=$(CURDIR)/tmp/go-mod20 GOMODCACHE?=$(CURDIR)/tmp/go-mod
18 GOCACHE?=$(CURDIR)/tmp/go-cache21 GOCACHE?=$(CURDIR)/tmp/go-cache
@@ -34,9 +37,10 @@ help: ## Show this help message
34 37
35 # Build targets38 # Build targets
36 build: build-frontend ## Build the application with GUI (frontend assets, then Go binary with WebKitGTK)39 build: build-frontend ## Build the application with GUI (frontend assets, then Go binary with WebKitGTK)
37- @echo "Building $(BINARY_NAME) $(VERSION) with GUI..."40+ @echo "Building $(BINARY_NAME) $(VERSION) with GUI using $(NPROCS) cores..."
38 @mkdir -p $(DIST_DIR) tmp tmp/go-cache tmp/go-mod41 @mkdir -p $(DIST_DIR) tmp tmp/go-cache tmp/go-mod
39- $(GOENV) CGO_ENABLED=1 go build $(LDFLAGS) -tags=webkit_cgo -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)42+ $(GOENV) CGO_ENABLED=1 go build -p $(NPROCS) $(LDFLAGS) -tags=webkit_cgo -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
43+ @echo "✅ Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
40 44
41 build-frontend: ## Build TypeScript frontend45 build-frontend: ## Build TypeScript frontend
42 @echo "Building TypeScript frontend..."46 @echo "Building TypeScript frontend..."
@@ -44,9 +48,10 @@ build-frontend: ## Build TypeScript frontend
44 @echo "Frontend build complete"48 @echo "Frontend build complete"
45 49
46 build-no-gui: build-frontend ## Build binary without GUI (CGO disabled, CLI-only functionality)50 build-no-gui: build-frontend ## Build binary without GUI (CGO disabled, CLI-only functionality)
47- @echo "Building $(BINARY_NAME) $(VERSION) (CLI-only, no GUI)..."51+ @echo "Building $(BINARY_NAME) $(VERSION) (CLI-only, no GUI) using $(NPROCS) cores..."
48 @mkdir -p $(DIST_DIR) tmp tmp/go-cache tmp/go-mod52 @mkdir -p $(DIST_DIR) tmp tmp/go-cache tmp/go-mod
49- $(GOENV) CGO_ENABLED=0 go build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-no-gui $(MAIN_PATH)53+ $(GOENV) CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-no-gui $(MAIN_PATH)
54+ @echo "✅ Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)-no-gui"
50 55
51 build-static: build-no-gui ## Alias for build-no-gui (backward compatibility)56 build-static: build-no-gui ## Alias for build-no-gui (backward compatibility)
52 @echo "Note: build-static is deprecated, use build-no-gui instead"57 @echo "Note: build-static is deprecated, use build-no-gui instead"
@@ -57,9 +62,9 @@ build-gui: build ## Alias for default build (backward compatibility)
57 @echo "Note: build-gui is now the default 'build' target"62 @echo "Note: build-gui is now the default 'build' target"
58 63
59 run-gui: ## Run the GUI with native WebKitGTK 6.0 (requires GTK4/WebKitGTK 6 dev packages)64 run-gui: ## Run the GUI with native WebKitGTK 6.0 (requires GTK4/WebKitGTK 6 dev packages)
60- @echo "Running GUI (webkit_cgo)…"65+ @echo "Running GUI (webkit_cgo) using $(NPROCS) cores…"
61 @mkdir -p tmp tmp/go-cache tmp/go-mod66 @mkdir -p tmp tmp/go-cache tmp/go-mod
62- $(GOENV) CGO_ENABLED=1 go run -tags=webkit_cgo $(MAIN_PATH)67+ $(GOENV) CGO_ENABLED=1 go run -p $(NPROCS) -tags=webkit_cgo $(MAIN_PATH)
63 68
64 .PHONY: check-webkit69 .PHONY: check-webkit
65 check-webkit: ## Check system has GTK4/WebKitGTK 6.0/JavaScriptCore 6.070 check-webkit: ## Check system has GTK4/WebKitGTK 6.0/JavaScriptCore 6.0
M internal/app/browser/browser.go +10 -0
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/dumber/internal/app/messaging"
1616 "github.com/bnema/dumber/internal/config"
1717 "github.com/bnema/dumber/internal/db"
18+ "github.com/bnema/dumber/internal/filtering"
1819 "github.com/bnema/dumber/internal/logging"
1920 "github.com/bnema/dumber/pkg/webkit"
2021 "github.com/bnema/dumber/services"
@@ -42,6 +43,9 @@ type BrowserApp struct {
4243 navigationController *control.NavigationController
4344 clipboardController *control.ClipboardController
4445
46+ // Content filtering
47+ filterManager *filtering.FilterManager
48+
4549 // Handlers
4650 schemeHandler *api.SchemeHandler
4751 messageHandler *messaging.Handler
@@ -142,6 +146,12 @@ func (app *BrowserApp) Run() {
142146 return
143147 }
144148
149+ // Initialize content blocking
150+ if err := app.setupContentBlocking(); err != nil {
151+ log.Printf("Warning: failed to setup content blocking: %v", err)
152+ // Continue without content blocking
153+ }
154+
145155 // Handle browse command if present
146156 app.navigationController.HandleBrowseCommand()
147157
@@ -15,6 +15,7 @@ import (
15 "github.com/bnema/dumber/internal/app/messaging"15 "github.com/bnema/dumber/internal/app/messaging"
16 "github.com/bnema/dumber/internal/config"16 "github.com/bnema/dumber/internal/config"
17 "github.com/bnema/dumber/internal/db"17 "github.com/bnema/dumber/internal/db"
18+ "github.com/bnema/dumber/internal/filtering"
18 "github.com/bnema/dumber/internal/logging"19 "github.com/bnema/dumber/internal/logging"
19 "github.com/bnema/dumber/pkg/webkit"20 "github.com/bnema/dumber/pkg/webkit"
20 "github.com/bnema/dumber/services"21 "github.com/bnema/dumber/services"
@@ -42,6 +43,9 @@ type BrowserApp struct {
42 navigationController *control.NavigationController43 navigationController *control.NavigationController
43 clipboardController *control.ClipboardController44 clipboardController *control.ClipboardController
44 45
46+ // Content filtering
47+ filterManager *filtering.FilterManager
48+
45 // Handlers49 // Handlers
46 schemeHandler *api.SchemeHandler50 schemeHandler *api.SchemeHandler
47 messageHandler *messaging.Handler51 messageHandler *messaging.Handler
@@ -142,6 +146,12 @@ func (app *BrowserApp) Run() {
142 return146 return
143 }147 }
144 148
149+ // Initialize content blocking
150+ if err := app.setupContentBlocking(); err != nil {
151+ log.Printf("Warning: failed to setup content blocking: %v", err)
152+ // Continue without content blocking
153+ }
154+
145 // Handle browse command if present155 // Handle browse command if present
146 app.navigationController.HandleBrowseCommand()156 app.navigationController.HandleBrowseCommand()
147 157
M internal/app/browser/webview.go +78 -0
@@ -6,11 +6,13 @@ import (
66 "os"
77 "path/filepath"
88 "strings"
9+ "time"
910
1011 "github.com/bnema/dumber/internal/app/constants"
1112 "github.com/bnema/dumber/internal/app/control"
1213 "github.com/bnema/dumber/internal/app/environment"
1314 "github.com/bnema/dumber/internal/config"
15+ "github.com/bnema/dumber/internal/filtering"
1416 "github.com/bnema/dumber/pkg/webkit"
1517 )
1618
@@ -150,3 +152,79 @@ func (app *BrowserApp) setupKeyboardShortcuts() {
150152 app.shortcutHandler = NewShortcutHandler(app.webView, app.clipboardController)
151153 app.shortcutHandler.RegisterShortcuts()
152154 }
155+
156+// setupContentBlocking initializes the content blocking system with proper timing
157+func (app *BrowserApp) setupContentBlocking() error {
158+ log.Printf("Initializing content blocking system...")
159+
160+ // Enable WebKit debug logging if requested
161+ webkit.SetupWebKitDebugLogging(app.config)
162+
163+ defer func() {
164+ if r := recover(); r != nil {
165+ log.Printf("Content blocking setup panic recovered: %v", r)
166+ }
167+ }()
168+
169+ // Setup filter system
170+ filterManager, err := filtering.SetupFilterSystem()
171+ if err != nil {
172+ log.Printf("Warning: Failed to setup filter system: %v", err)
173+ return nil // Don't fail browser startup, continue without filters
174+ }
175+
176+ // Store reference to filter manager
177+ app.filterManager = filterManager
178+
179+ // Set up callback to re-apply network filters when they become ready
180+ // This callback will be called from the async filter loading process
181+ filterManager.SetFiltersReadyCallback(func() {
182+ // Add small delay to avoid race conditions with WebView initialization
183+ go func() {
184+ time.Sleep(300 * time.Millisecond)
185+ if app.webView != nil {
186+ if err := app.webView.UpdateContentFilters(filterManager); err != nil {
187+ log.Printf("Failed to apply filters after loading: %v", err)
188+ } else {
189+ log.Printf("Successfully applied filters after async loading")
190+ }
191+ }
192+ }()
193+ })
194+
195+ // Start async filter loading early (before WebView content blocking initialization)
196+ // This allows filters to be ready when the WebView needs them
197+ go func() {
198+ if err := filtering.InitializeFiltersAsync(filterManager); err != nil {
199+ log.Printf("Warning: failed to initialize filters asynchronously: %v", err)
200+ }
201+ }()
202+
203+ // Initialize content blocking in WebView with delay
204+ // Wait for WebView to be fully loaded before setting up content blocking
205+ go func() {
206+ // Wait extra time for the first navigation to avoid preconnect interference
207+ log.Printf("Waiting for WebView to complete initial load before enabling content blocking...")
208+ time.Sleep(3000 * time.Millisecond) // 3 seconds for first load stability
209+
210+ if err := app.webView.InitializeContentBlocking(filterManager); err != nil {
211+ log.Printf("Warning: Failed to initialize content blocking: %v", err)
212+ // Continue without content blocking rather than failing
213+ }
214+
215+ // Register navigation handler for domain-specific filtering
216+ // Only register after content blocking is initialized
217+ app.webView.RegisterURIChangedHandler(func(uri string) {
218+ // Add small delay to avoid conflicts with page load
219+ go func() {
220+ time.Sleep(200 * time.Millisecond) // Slightly longer delay for stability
221+ if app.filterManager != nil {
222+ app.webView.OnNavigate(uri, app.filterManager)
223+ }
224+ }()
225+ })
226+ }()
227+
228+ log.Printf("Content blocking system initialization started")
229+ return nil
230+}
@@ -6,11 +6,13 @@ import (
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8 "strings"8 "strings"
9+ "time"
9 10
10 "github.com/bnema/dumber/internal/app/constants"11 "github.com/bnema/dumber/internal/app/constants"
11 "github.com/bnema/dumber/internal/app/control"12 "github.com/bnema/dumber/internal/app/control"
12 "github.com/bnema/dumber/internal/app/environment"13 "github.com/bnema/dumber/internal/app/environment"
13 "github.com/bnema/dumber/internal/config"14 "github.com/bnema/dumber/internal/config"
15+ "github.com/bnema/dumber/internal/filtering"
14 "github.com/bnema/dumber/pkg/webkit"16 "github.com/bnema/dumber/pkg/webkit"
15 )17 )
16 18
@@ -150,3 +152,79 @@ func (app *BrowserApp) setupKeyboardShortcuts() {
150 app.shortcutHandler = NewShortcutHandler(app.webView, app.clipboardController)152 app.shortcutHandler = NewShortcutHandler(app.webView, app.clipboardController)
151 app.shortcutHandler.RegisterShortcuts()153 app.shortcutHandler.RegisterShortcuts()
152 }154 }
155+
156+// setupContentBlocking initializes the content blocking system with proper timing
157+func (app *BrowserApp) setupContentBlocking() error {
158+ log.Printf("Initializing content blocking system...")
159+
160+ // Enable WebKit debug logging if requested
161+ webkit.SetupWebKitDebugLogging(app.config)
162+
163+ defer func() {
164+ if r := recover(); r != nil {
165+ log.Printf("Content blocking setup panic recovered: %v", r)
166+ }
167+ }()
168+
169+ // Setup filter system
170+ filterManager, err := filtering.SetupFilterSystem()
171+ if err != nil {
172+ log.Printf("Warning: Failed to setup filter system: %v", err)
173+ return nil // Don't fail browser startup, continue without filters
174+ }
175+
176+ // Store reference to filter manager
177+ app.filterManager = filterManager
178+
179+ // Set up callback to re-apply network filters when they become ready
180+ // This callback will be called from the async filter loading process
181+ filterManager.SetFiltersReadyCallback(func() {
182+ // Add small delay to avoid race conditions with WebView initialization
183+ go func() {
184+ time.Sleep(300 * time.Millisecond)
185+ if app.webView != nil {
186+ if err := app.webView.UpdateContentFilters(filterManager); err != nil {
187+ log.Printf("Failed to apply filters after loading: %v", err)
188+ } else {
189+ log.Printf("Successfully applied filters after async loading")
190+ }
191+ }
192+ }()
193+ })
194+
195+ // Start async filter loading early (before WebView content blocking initialization)
196+ // This allows filters to be ready when the WebView needs them
197+ go func() {
198+ if err := filtering.InitializeFiltersAsync(filterManager); err != nil {
199+ log.Printf("Warning: failed to initialize filters asynchronously: %v", err)
200+ }
201+ }()
202+
203+ // Initialize content blocking in WebView with delay
204+ // Wait for WebView to be fully loaded before setting up content blocking
205+ go func() {
206+ // Wait extra time for the first navigation to avoid preconnect interference
207+ log.Printf("Waiting for WebView to complete initial load before enabling content blocking...")
208+ time.Sleep(3000 * time.Millisecond) // 3 seconds for first load stability
209+
210+ if err := app.webView.InitializeContentBlocking(filterManager); err != nil {
211+ log.Printf("Warning: Failed to initialize content blocking: %v", err)
212+ // Continue without content blocking rather than failing
213+ }
214+
215+ // Register navigation handler for domain-specific filtering
216+ // Only register after content blocking is initialized
217+ app.webView.RegisterURIChangedHandler(func(uri string) {
218+ // Add small delay to avoid conflicts with page load
219+ go func() {
220+ time.Sleep(200 * time.Millisecond) // Slightly longer delay for stability
221+ if app.filterManager != nil {
222+ app.webView.OnNavigate(uri, app.filterManager)
223+ }
224+ }()
225+ })
226+ }()
227+
228+ log.Printf("Content blocking system initialization started")
229+ return nil
230+}
A internal/cache/filter_cache.go +391 -0
@@ -0,0 +1,391 @@
1+package cache
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "os"
7+ "syscall"
8+ "time"
9+ "unsafe"
10+
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// FilterData represents the compiled filter data for caching
15+type FilterData struct {
16+ Version uint32
17+ Created time.Time
18+ NetworkRules []NetworkRule
19+ CosmeticRules []CosmeticRule
20+}
21+
22+// NetworkRule represents a compiled network filtering rule
23+type NetworkRule struct {
24+ Pattern string
25+ Domain string
26+ Action uint8 // 0=block, 1=allow
27+ ResourceType uint32 // Bitmask of resource types
28+ Priority uint8
29+}
30+
31+// CosmeticRule represents a compiled cosmetic filtering rule
32+type CosmeticRule struct {
33+ Domain string
34+ Selector string
35+}
36+
37+const (
38+ // Cache format version for compatibility
39+ cacheFormatVersion = uint32(1)
40+
41+ // Magic bytes to identify cache files
42+ cacheMagic = uint64(0x44554D4245524301) // "DUMBERC" + version
43+
44+ // File permissions
45+ cacheFilePerms = 0644
46+)
47+
48+// FilterCache provides high-performance binary filter caching with memory mapping
49+type FilterCache struct {
50+ path string
51+ mmapData []byte
52+ size int64
53+}
54+
55+// CacheHeader represents the binary cache file header
56+type CacheHeader struct {
57+ Magic uint64 // Magic bytes for file identification
58+ Version uint32 // Cache format version
59+ DataSize uint64 // Size of data section
60+ RuleCount uint32 // Number of filter rules
61+ Created int64 // Unix timestamp when cache was created
62+ Checksum uint64 // CRC64 checksum of data section
63+}
64+
65+// FilterRule represents a compiled filter rule in binary format
66+type FilterRule struct {
67+ Type uint8 // Rule type (0=network, 1=cosmetic)
68+ Priority uint8 // Rule priority
69+ Action uint8 // Action to take (0=block, 1=allow, etc.)
70+ Flags uint8 // Additional flags
71+ PatternLen uint16 // Length of pattern string
72+ DomainLen uint16 // Length of domain string
73+ ResourceType uint32 // Bitmask of resource types
74+ // Followed by: Pattern (variable length), Domain (variable length)
75+}
76+
77+// NewFilterCache creates a new filter cache instance
78+func NewFilterCache(path string) *FilterCache {
79+ return &FilterCache{
80+ path: path,
81+ }
82+}
83+
84+// LoadMapped loads the cache file using memory mapping for zero-copy access
85+func (fc *FilterCache) LoadMapped() (*FilterData, error) {
86+ file, err := os.Open(fc.path)
87+ if err != nil {
88+ return nil, fmt.Errorf("failed to open cache file: %w", err)
89+ }
90+ defer file.Close()
91+
92+ // Get file size
93+ stat, err := file.Stat()
94+ if err != nil {
95+ return nil, fmt.Errorf("failed to stat cache file: %w", err)
96+ }
97+
98+ fc.size = stat.Size()
99+ if fc.size < int64(unsafe.Sizeof(CacheHeader{})) {
100+ return nil, fmt.Errorf("cache file too small")
101+ }
102+
103+ // Memory map the file for read-only access
104+ fc.mmapData, err = syscall.Mmap(int(file.Fd()), 0, int(fc.size), syscall.PROT_READ, syscall.MAP_SHARED)
105+ if err != nil {
106+ return nil, fmt.Errorf("failed to mmap cache file: %w", err)
107+ }
108+
109+ // Parse the header
110+ header, err := fc.parseHeader()
111+ if err != nil {
112+ fc.Unmap()
113+ return nil, fmt.Errorf("invalid cache header: %w", err)
114+ }
115+
116+ // Validate magic and version
117+ if header.Magic != cacheMagic {
118+ fc.Unmap()
119+ return nil, fmt.Errorf("invalid cache magic bytes")
120+ }
121+
122+ if header.Version != cacheFormatVersion {
123+ fc.Unmap()
124+ return nil, fmt.Errorf("unsupported cache version: %d", header.Version)
125+ }
126+
127+ // Parse filter data without copying
128+ data, err := fc.parseInPlace(header)
129+ if err != nil {
130+ fc.Unmap()
131+ return nil, fmt.Errorf("failed to parse cache data: %w", err)
132+ }
133+
134+ logging.Info(fmt.Sprintf("Loaded binary cache: %d rules, %d bytes (memory-mapped)",
135+ header.RuleCount, fc.size))
136+
137+ return data, nil
138+}
139+
140+// parseHeader extracts the cache header from memory-mapped data
141+func (fc *FilterCache) parseHeader() (*CacheHeader, error) {
142+ if len(fc.mmapData) < int(unsafe.Sizeof(CacheHeader{})) {
143+ return nil, fmt.Errorf("insufficient data for header")
144+ }
145+
146+ header := (*CacheHeader)(unsafe.Pointer(&fc.mmapData[0]))
147+ return header, nil
148+}
149+
150+// parseInPlace parses filter data directly from memory-mapped data without copying
151+func (fc *FilterCache) parseInPlace(header *CacheHeader) (*FilterData, error) {
152+ data := &FilterData{
153+ Version: header.Version,
154+ NetworkRules: make([]NetworkRule, 0, header.RuleCount),
155+ CosmeticRules: make([]CosmeticRule, 0),
156+ }
157+
158+ // Start parsing after the header
159+ offset := int(unsafe.Sizeof(CacheHeader{}))
160+ remaining := header.RuleCount
161+
162+ for remaining > 0 && offset < len(fc.mmapData) {
163+ // Parse rule header
164+ if offset+int(unsafe.Sizeof(FilterRule{})) > len(fc.mmapData) {
165+ break
166+ }
167+
168+ ruleHeader := (*FilterRule)(unsafe.Pointer(&fc.mmapData[offset]))
169+ offset += int(unsafe.Sizeof(FilterRule{}))
170+
171+ // Extract pattern string (zero-copy)
172+ if offset+int(ruleHeader.PatternLen) > len(fc.mmapData) {
173+ break
174+ }
175+ pattern := string(fc.mmapData[offset : offset+int(ruleHeader.PatternLen)])
176+ offset += int(ruleHeader.PatternLen)
177+
178+ // Extract domain string (zero-copy)
179+ if offset+int(ruleHeader.DomainLen) > len(fc.mmapData) {
180+ break
181+ }
182+ domain := string(fc.mmapData[offset : offset+int(ruleHeader.DomainLen)])
183+ offset += int(ruleHeader.DomainLen)
184+
185+ // Create appropriate rule type
186+ switch ruleHeader.Type {
187+ case 0: // Network rule
188+ data.NetworkRules = append(data.NetworkRules, NetworkRule{
189+ Pattern: pattern,
190+ Domain: domain,
191+ Action: ruleHeader.Action,
192+ ResourceType: ruleHeader.ResourceType,
193+ Priority: ruleHeader.Priority,
194+ })
195+ case 1: // Cosmetic rule
196+ data.CosmeticRules = append(data.CosmeticRules, CosmeticRule{
197+ Domain: domain,
198+ Selector: pattern,
199+ })
200+ }
201+
202+ remaining--
203+ }
204+
205+ return data, nil
206+}
207+
208+// Write saves filter data to cache in binary format
209+func (fc *FilterCache) Write(data *FilterData) error {
210+ // Create temporary file for atomic write
211+ tmpPath := fc.path + ".tmp"
212+ file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, cacheFilePerms)
213+ if err != nil {
214+ return fmt.Errorf("failed to create cache file: %w", err)
215+ }
216+ defer file.Close()
217+
218+ // Calculate total rule count
219+ totalRules := uint32(len(data.NetworkRules) + len(data.CosmeticRules))
220+
221+ // Write header
222+ header := CacheHeader{
223+ Magic: cacheMagic,
224+ Version: data.Version,
225+ RuleCount: totalRules,
226+ Created: data.Created.Unix(),
227+ DataSize: 0, // Will be calculated
228+ Checksum: 0, // Will be calculated
229+ }
230+
231+ if err := binary.Write(file, binary.LittleEndian, header); err != nil {
232+ os.Remove(tmpPath)
233+ return fmt.Errorf("failed to write header: %w", err)
234+ }
235+
236+ dataStart, _ := file.Seek(0, os.SEEK_CUR)
237+
238+ // Write network rules
239+ for _, rule := range data.NetworkRules {
240+ if err := fc.writeNetworkRule(file, rule); err != nil {
241+ os.Remove(tmpPath)
242+ return fmt.Errorf("failed to write network rule: %w", err)
243+ }
244+ }
245+
246+ // Write cosmetic rules
247+ for _, rule := range data.CosmeticRules {
248+ if err := fc.writeCosmeticRule(file, rule); err != nil {
249+ os.Remove(tmpPath)
250+ return fmt.Errorf("failed to write cosmetic rule: %w", err)
251+ }
252+ }
253+
254+ // Calculate data size
255+ dataEnd, _ := file.Seek(0, os.SEEK_CUR)
256+ dataSize := uint64(dataEnd - dataStart)
257+
258+ // Update header with data size
259+ if _, err := file.Seek(0, os.SEEK_SET); err != nil {
260+ os.Remove(tmpPath)
261+ return err
262+ }
263+
264+ header.DataSize = dataSize
265+ // TODO: Calculate and set checksum
266+
267+ if err := binary.Write(file, binary.LittleEndian, header); err != nil {
268+ os.Remove(tmpPath)
269+ return fmt.Errorf("failed to update header: %w", err)
270+ }
271+
272+ if err := file.Sync(); err != nil {
273+ os.Remove(tmpPath)
274+ return fmt.Errorf("failed to sync cache file: %w", err)
275+ }
276+
277+ file.Close()
278+
279+ // Atomic rename
280+ if err := os.Rename(tmpPath, fc.path); err != nil {
281+ os.Remove(tmpPath)
282+ return fmt.Errorf("failed to rename cache file: %w", err)
283+ }
284+
285+ logging.Info(fmt.Sprintf("Wrote binary cache: %d rules, %d bytes", totalRules, dataSize))
286+ return nil
287+}
288+
289+// writeNetworkRule writes a network rule in binary format
290+func (fc *FilterCache) writeNetworkRule(file *os.File, rule NetworkRule) error {
291+ patternBytes := []byte(rule.Pattern)
292+ domainBytes := []byte(rule.Domain)
293+
294+ ruleHeader := FilterRule{
295+ Type: 0, // Network rule
296+ Priority: rule.Priority,
297+ Action: rule.Action,
298+ Flags: 0,
299+ PatternLen: uint16(len(patternBytes)),
300+ DomainLen: uint16(len(domainBytes)),
301+ ResourceType: rule.ResourceType,
302+ }
303+
304+ // Write rule header
305+ if err := binary.Write(file, binary.LittleEndian, ruleHeader); err != nil {
306+ return err
307+ }
308+
309+ // Write pattern
310+ if _, err := file.Write(patternBytes); err != nil {
311+ return err
312+ }
313+
314+ // Write domain
315+ if _, err := file.Write(domainBytes); err != nil {
316+ return err
317+ }
318+
319+ return nil
320+}
321+
322+// writeCosmeticRule writes a cosmetic rule in binary format
323+func (fc *FilterCache) writeCosmeticRule(file *os.File, rule CosmeticRule) error {
324+ selectorBytes := []byte(rule.Selector)
325+ domainBytes := []byte(rule.Domain)
326+
327+ ruleHeader := FilterRule{
328+ Type: 1, // Cosmetic rule
329+ Priority: 0,
330+ Action: 0, // Hide
331+ Flags: 0,
332+ PatternLen: uint16(len(selectorBytes)),
333+ DomainLen: uint16(len(domainBytes)),
334+ ResourceType: 0,
335+ }
336+
337+ // Write rule header
338+ if err := binary.Write(file, binary.LittleEndian, ruleHeader); err != nil {
339+ return err
340+ }
341+
342+ // Write selector
343+ if _, err := file.Write(selectorBytes); err != nil {
344+ return err
345+ }
346+
347+ // Write domain
348+ if _, err := file.Write(domainBytes); err != nil {
349+ return err
350+ }
351+
352+ return nil
353+}
354+
355+// Unmap releases the memory-mapped data
356+func (fc *FilterCache) Unmap() error {
357+ if fc.mmapData != nil {
358+ if err := syscall.Munmap(fc.mmapData); err != nil {
359+ return fmt.Errorf("failed to unmap cache data: %w", err)
360+ }
361+ fc.mmapData = nil
362+ }
363+ return nil
364+}
365+
366+// Size returns the size of the cache file in bytes
367+func (fc *FilterCache) Size() int64 {
368+ return fc.size
369+}
370+
371+// IsValid checks if the cache file exists and has valid format
372+func (fc *FilterCache) IsValid() bool {
373+ file, err := os.Open(fc.path)
374+ if err != nil {
375+ return false
376+ }
377+ defer file.Close()
378+
379+ var magic uint64
380+ if err := binary.Read(file, binary.LittleEndian, &magic); err != nil {
381+ return false
382+ }
383+
384+ return magic == cacheMagic
385+}
386+
387+// Remove deletes the cache file
388+func (fc *FilterCache) Remove() error {
389+ fc.Unmap() // Unmap first if mapped
390+ return os.Remove(fc.path)
391+}
@@ -0,0 +1,391 @@
1+package cache
2+
3+import (
4+ "encoding/binary"
5+ "fmt"
6+ "os"
7+ "syscall"
8+ "time"
9+ "unsafe"
10+
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// FilterData represents the compiled filter data for caching
15+type FilterData struct {
16+ Version uint32
17+ Created time.Time
18+ NetworkRules []NetworkRule
19+ CosmeticRules []CosmeticRule
20+}
21+
22+// NetworkRule represents a compiled network filtering rule
23+type NetworkRule struct {
24+ Pattern string
25+ Domain string
26+ Action uint8 // 0=block, 1=allow
27+ ResourceType uint32 // Bitmask of resource types
28+ Priority uint8
29+}
30+
31+// CosmeticRule represents a compiled cosmetic filtering rule
32+type CosmeticRule struct {
33+ Domain string
34+ Selector string
35+}
36+
37+const (
38+ // Cache format version for compatibility
39+ cacheFormatVersion = uint32(1)
40+
41+ // Magic bytes to identify cache files
42+ cacheMagic = uint64(0x44554D4245524301) // "DUMBERC" + version
43+
44+ // File permissions
45+ cacheFilePerms = 0644
46+)
47+
48+// FilterCache provides high-performance binary filter caching with memory mapping
49+type FilterCache struct {
50+ path string
51+ mmapData []byte
52+ size int64
53+}
54+
55+// CacheHeader represents the binary cache file header
56+type CacheHeader struct {
57+ Magic uint64 // Magic bytes for file identification
58+ Version uint32 // Cache format version
59+ DataSize uint64 // Size of data section
60+ RuleCount uint32 // Number of filter rules
61+ Created int64 // Unix timestamp when cache was created
62+ Checksum uint64 // CRC64 checksum of data section
63+}
64+
65+// FilterRule represents a compiled filter rule in binary format
66+type FilterRule struct {
67+ Type uint8 // Rule type (0=network, 1=cosmetic)
68+ Priority uint8 // Rule priority
69+ Action uint8 // Action to take (0=block, 1=allow, etc.)
70+ Flags uint8 // Additional flags
71+ PatternLen uint16 // Length of pattern string
72+ DomainLen uint16 // Length of domain string
73+ ResourceType uint32 // Bitmask of resource types
74+ // Followed by: Pattern (variable length), Domain (variable length)
75+}
76+
77+// NewFilterCache creates a new filter cache instance
78+func NewFilterCache(path string) *FilterCache {
79+ return &FilterCache{
80+ path: path,
81+ }
82+}
83+
84+// LoadMapped loads the cache file using memory mapping for zero-copy access
85+func (fc *FilterCache) LoadMapped() (*FilterData, error) {
86+ file, err := os.Open(fc.path)
87+ if err != nil {
88+ return nil, fmt.Errorf("failed to open cache file: %w", err)
89+ }
90+ defer file.Close()
91+
92+ // Get file size
93+ stat, err := file.Stat()
94+ if err != nil {
95+ return nil, fmt.Errorf("failed to stat cache file: %w", err)
96+ }
97+
98+ fc.size = stat.Size()
99+ if fc.size < int64(unsafe.Sizeof(CacheHeader{})) {
100+ return nil, fmt.Errorf("cache file too small")
101+ }
102+
103+ // Memory map the file for read-only access
104+ fc.mmapData, err = syscall.Mmap(int(file.Fd()), 0, int(fc.size), syscall.PROT_READ, syscall.MAP_SHARED)
105+ if err != nil {
106+ return nil, fmt.Errorf("failed to mmap cache file: %w", err)
107+ }
108+
109+ // Parse the header
110+ header, err := fc.parseHeader()
111+ if err != nil {
112+ fc.Unmap()
113+ return nil, fmt.Errorf("invalid cache header: %w", err)
114+ }
115+
116+ // Validate magic and version
117+ if header.Magic != cacheMagic {
118+ fc.Unmap()
119+ return nil, fmt.Errorf("invalid cache magic bytes")
120+ }
121+
122+ if header.Version != cacheFormatVersion {
123+ fc.Unmap()
124+ return nil, fmt.Errorf("unsupported cache version: %d", header.Version)
125+ }
126+
127+ // Parse filter data without copying
128+ data, err := fc.parseInPlace(header)
129+ if err != nil {
130+ fc.Unmap()
131+ return nil, fmt.Errorf("failed to parse cache data: %w", err)
132+ }
133+
134+ logging.Info(fmt.Sprintf("Loaded binary cache: %d rules, %d bytes (memory-mapped)",
135+ header.RuleCount, fc.size))
136+
137+ return data, nil
138+}
139+
140+// parseHeader extracts the cache header from memory-mapped data
141+func (fc *FilterCache) parseHeader() (*CacheHeader, error) {
142+ if len(fc.mmapData) < int(unsafe.Sizeof(CacheHeader{})) {
143+ return nil, fmt.Errorf("insufficient data for header")
144+ }
145+
146+ header := (*CacheHeader)(unsafe.Pointer(&fc.mmapData[0]))
147+ return header, nil
148+}
149+
150+// parseInPlace parses filter data directly from memory-mapped data without copying
151+func (fc *FilterCache) parseInPlace(header *CacheHeader) (*FilterData, error) {
152+ data := &FilterData{
153+ Version: header.Version,
154+ NetworkRules: make([]NetworkRule, 0, header.RuleCount),
155+ CosmeticRules: make([]CosmeticRule, 0),
156+ }
157+
158+ // Start parsing after the header
159+ offset := int(unsafe.Sizeof(CacheHeader{}))
160+ remaining := header.RuleCount
161+
162+ for remaining > 0 && offset < len(fc.mmapData) {
163+ // Parse rule header
164+ if offset+int(unsafe.Sizeof(FilterRule{})) > len(fc.mmapData) {
165+ break
166+ }
167+
168+ ruleHeader := (*FilterRule)(unsafe.Pointer(&fc.mmapData[offset]))
169+ offset += int(unsafe.Sizeof(FilterRule{}))
170+
171+ // Extract pattern string (zero-copy)
172+ if offset+int(ruleHeader.PatternLen) > len(fc.mmapData) {
173+ break
174+ }
175+ pattern := string(fc.mmapData[offset : offset+int(ruleHeader.PatternLen)])
176+ offset += int(ruleHeader.PatternLen)
177+
178+ // Extract domain string (zero-copy)
179+ if offset+int(ruleHeader.DomainLen) > len(fc.mmapData) {
180+ break
181+ }
182+ domain := string(fc.mmapData[offset : offset+int(ruleHeader.DomainLen)])
183+ offset += int(ruleHeader.DomainLen)
184+
185+ // Create appropriate rule type
186+ switch ruleHeader.Type {
187+ case 0: // Network rule
188+ data.NetworkRules = append(data.NetworkRules, NetworkRule{
189+ Pattern: pattern,
190+ Domain: domain,
191+ Action: ruleHeader.Action,
192+ ResourceType: ruleHeader.ResourceType,
193+ Priority: ruleHeader.Priority,
194+ })
195+ case 1: // Cosmetic rule
196+ data.CosmeticRules = append(data.CosmeticRules, CosmeticRule{
197+ Domain: domain,
198+ Selector: pattern,
199+ })
200+ }
201+
202+ remaining--
203+ }
204+
205+ return data, nil
206+}
207+
208+// Write saves filter data to cache in binary format
209+func (fc *FilterCache) Write(data *FilterData) error {
210+ // Create temporary file for atomic write
211+ tmpPath := fc.path + ".tmp"
212+ file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, cacheFilePerms)
213+ if err != nil {
214+ return fmt.Errorf("failed to create cache file: %w", err)
215+ }
216+ defer file.Close()
217+
218+ // Calculate total rule count
219+ totalRules := uint32(len(data.NetworkRules) + len(data.CosmeticRules))
220+
221+ // Write header
222+ header := CacheHeader{
223+ Magic: cacheMagic,
224+ Version: data.Version,
225+ RuleCount: totalRules,
226+ Created: data.Created.Unix(),
227+ DataSize: 0, // Will be calculated
228+ Checksum: 0, // Will be calculated
229+ }
230+
231+ if err := binary.Write(file, binary.LittleEndian, header); err != nil {
232+ os.Remove(tmpPath)
233+ return fmt.Errorf("failed to write header: %w", err)
234+ }
235+
236+ dataStart, _ := file.Seek(0, os.SEEK_CUR)
237+
238+ // Write network rules
239+ for _, rule := range data.NetworkRules {
240+ if err := fc.writeNetworkRule(file, rule); err != nil {
241+ os.Remove(tmpPath)
242+ return fmt.Errorf("failed to write network rule: %w", err)
243+ }
244+ }
245+
246+ // Write cosmetic rules
247+ for _, rule := range data.CosmeticRules {
248+ if err := fc.writeCosmeticRule(file, rule); err != nil {
249+ os.Remove(tmpPath)
250+ return fmt.Errorf("failed to write cosmetic rule: %w", err)
251+ }
252+ }
253+
254+ // Calculate data size
255+ dataEnd, _ := file.Seek(0, os.SEEK_CUR)
256+ dataSize := uint64(dataEnd - dataStart)
257+
258+ // Update header with data size
259+ if _, err := file.Seek(0, os.SEEK_SET); err != nil {
260+ os.Remove(tmpPath)
261+ return err
262+ }
263+
264+ header.DataSize = dataSize
265+ // TODO: Calculate and set checksum
266+
267+ if err := binary.Write(file, binary.LittleEndian, header); err != nil {
268+ os.Remove(tmpPath)
269+ return fmt.Errorf("failed to update header: %w", err)
270+ }
271+
272+ if err := file.Sync(); err != nil {
273+ os.Remove(tmpPath)
274+ return fmt.Errorf("failed to sync cache file: %w", err)
275+ }
276+
277+ file.Close()
278+
279+ // Atomic rename
280+ if err := os.Rename(tmpPath, fc.path); err != nil {
281+ os.Remove(tmpPath)
282+ return fmt.Errorf("failed to rename cache file: %w", err)
283+ }
284+
285+ logging.Info(fmt.Sprintf("Wrote binary cache: %d rules, %d bytes", totalRules, dataSize))
286+ return nil
287+}
288+
289+// writeNetworkRule writes a network rule in binary format
290+func (fc *FilterCache) writeNetworkRule(file *os.File, rule NetworkRule) error {
291+ patternBytes := []byte(rule.Pattern)
292+ domainBytes := []byte(rule.Domain)
293+
294+ ruleHeader := FilterRule{
295+ Type: 0, // Network rule
296+ Priority: rule.Priority,
297+ Action: rule.Action,
298+ Flags: 0,
299+ PatternLen: uint16(len(patternBytes)),
300+ DomainLen: uint16(len(domainBytes)),
301+ ResourceType: rule.ResourceType,
302+ }
303+
304+ // Write rule header
305+ if err := binary.Write(file, binary.LittleEndian, ruleHeader); err != nil {
306+ return err
307+ }
308+
309+ // Write pattern
310+ if _, err := file.Write(patternBytes); err != nil {
311+ return err
312+ }
313+
314+ // Write domain
315+ if _, err := file.Write(domainBytes); err != nil {
316+ return err
317+ }
318+
319+ return nil
320+}
321+
322+// writeCosmeticRule writes a cosmetic rule in binary format
323+func (fc *FilterCache) writeCosmeticRule(file *os.File, rule CosmeticRule) error {
324+ selectorBytes := []byte(rule.Selector)
325+ domainBytes := []byte(rule.Domain)
326+
327+ ruleHeader := FilterRule{
328+ Type: 1, // Cosmetic rule
329+ Priority: 0,
330+ Action: 0, // Hide
331+ Flags: 0,
332+ PatternLen: uint16(len(selectorBytes)),
333+ DomainLen: uint16(len(domainBytes)),
334+ ResourceType: 0,
335+ }
336+
337+ // Write rule header
338+ if err := binary.Write(file, binary.LittleEndian, ruleHeader); err != nil {
339+ return err
340+ }
341+
342+ // Write selector
343+ if _, err := file.Write(selectorBytes); err != nil {
344+ return err
345+ }
346+
347+ // Write domain
348+ if _, err := file.Write(domainBytes); err != nil {
349+ return err
350+ }
351+
352+ return nil
353+}
354+
355+// Unmap releases the memory-mapped data
356+func (fc *FilterCache) Unmap() error {
357+ if fc.mmapData != nil {
358+ if err := syscall.Munmap(fc.mmapData); err != nil {
359+ return fmt.Errorf("failed to unmap cache data: %w", err)
360+ }
361+ fc.mmapData = nil
362+ }
363+ return nil
364+}
365+
366+// Size returns the size of the cache file in bytes
367+func (fc *FilterCache) Size() int64 {
368+ return fc.size
369+}
370+
371+// IsValid checks if the cache file exists and has valid format
372+func (fc *FilterCache) IsValid() bool {
373+ file, err := os.Open(fc.path)
374+ if err != nil {
375+ return false
376+ }
377+ defer file.Close()
378+
379+ var magic uint64
380+ if err := binary.Read(file, binary.LittleEndian, &magic); err != nil {
381+ return false
382+ }
383+
384+ return magic == cacheMagic
385+}
386+
387+// Remove deletes the cache file
388+func (fc *FilterCache) Remove() error {
389+ fc.Unmap() // Unmap first if mapped
390+ return os.Remove(fc.path)
391+}
M internal/cli/purge.go +19 -6
@@ -28,6 +28,7 @@ type PurgeFlags struct {
2828 HistoryCache bool
2929 BrowserCache bool
3030 BrowserData bool
31+ FilterCache bool
3132 State bool
3233 Config bool
3334 All bool
@@ -48,6 +49,7 @@ Available purge targets:
4849 --history-cache, -H Purge dmenu fuzzy search cache for history
4950 --browser-cache, -c Purge WebKit browser cache (cached images, files, etc.)
5051 --browser-data, -b Purge WebKit browser data (cookies, localStorage, sessionStorage)
52+ --filter-cache, -F Purge compiled ad blocking filters cache
5153 --state, -s Purge all state data (includes database and caches)
5254 --config Purge configuration files
5355 --all, -a Purge everything (default if no specific flags are provided)
@@ -57,8 +59,8 @@ Use --force to skip the confirmation prompt.
5759 Examples:
5860 dumber purge # Purge everything (with confirmation)
5961 dumber purge --force # Purge everything (no confirmation)
60- dumber purge -d -H -c # Purge database and both caches
61- dumber purge --browser-data -f # Force purge browser data only`,
62+ dumber purge -d -H -c -F # Purge database and all caches
63+ dumber purge --filter-cache -f # Force purge filter cache only`,
6264 RunE: func(_ *cobra.Command, _ []string) error {
6365 // Initialize CLI to get config paths
6466 cli, err := NewCLI()
@@ -80,6 +82,7 @@ Examples:
8082 cmd.Flags().BoolVarP(&flags.HistoryCache, "history-cache", "H", false, "Purge dmenu fuzzy search cache")
8183 cmd.Flags().BoolVarP(&flags.BrowserCache, "browser-cache", "c", false, "Purge WebKit browser cache")
8284 cmd.Flags().BoolVarP(&flags.BrowserData, "browser-data", "b", false, "Purge WebKit browser data (cookies, localStorage)")
85+ cmd.Flags().BoolVarP(&flags.FilterCache, "filter-cache", "F", false, "Purge compiled ad blocking filters cache")
8386 cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data")
8487 cmd.Flags().BoolVar(&flags.Config, "config", false, "Purge configuration files")
8588 cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Purge everything")
@@ -120,8 +123,8 @@ func determinePurgeItems(flags PurgeFlags) []string {
120123 var items []string
121124
122125 // If no specific flags are set, or --all is set, purge everything
123- if flags.All || (!flags.Database && !flags.HistoryCache && !flags.BrowserCache && !flags.BrowserData && !flags.State && !flags.Config) {
124- return []string{"database", "history-cache", "browser-cache", "browser-data", "config"}
126+ if flags.All || (!flags.Database && !flags.HistoryCache && !flags.BrowserCache && !flags.BrowserData && !flags.FilterCache && !flags.State && !flags.Config) {
127+ return []string{"database", "history-cache", "browser-cache", "browser-data", "filter-cache", "config"}
125128 }
126129
127130 // Add items based on flags
@@ -137,9 +140,12 @@ func determinePurgeItems(flags PurgeFlags) []string {
137140 if flags.BrowserData {
138141 items = append(items, "browser-data")
139142 }
143+ if flags.FilterCache {
144+ items = append(items, "filter-cache")
145+ }
140146 if flags.State {
141- // State includes database and both caches
142- items = append(items, "database", "history-cache", "browser-cache")
147+ // State includes database and all caches
148+ items = append(items, "database", "history-cache", "browser-cache", "filter-cache")
143149 }
144150 if flags.Config {
145151 items = append(items, "config")
@@ -195,6 +201,13 @@ func getPurgePaths(items []string) (map[string][]string, error) {
195201 webkitData := filepath.Join(dataDir, "webkit")
196202 paths[item] = []string{webkitData}
197203
204+ case "filter-cache":
205+ filterCacheDir, err := config.GetFilterCacheDir()
206+ if err != nil {
207+ return nil, fmt.Errorf("failed to get filter cache directory: %w", err)
208+ }
209+ paths[item] = []string{filterCacheDir}
210+
198211 case "config":
199212 configFile, err := config.GetConfigFile()
200213 if err != nil {
@@ -28,6 +28,7 @@ type PurgeFlags struct {
28 HistoryCache bool28 HistoryCache bool
29 BrowserCache bool29 BrowserCache bool
30 BrowserData bool30 BrowserData bool
31+ FilterCache bool
31 State bool32 State bool
32 Config bool33 Config bool
33 All bool34 All bool
@@ -48,6 +49,7 @@ Available purge targets:
48 --history-cache, -H Purge dmenu fuzzy search cache for history49 --history-cache, -H Purge dmenu fuzzy search cache for history
49 --browser-cache, -c Purge WebKit browser cache (cached images, files, etc.)50 --browser-cache, -c Purge WebKit browser cache (cached images, files, etc.)
50 --browser-data, -b Purge WebKit browser data (cookies, localStorage, sessionStorage)51 --browser-data, -b Purge WebKit browser data (cookies, localStorage, sessionStorage)
52+ --filter-cache, -F Purge compiled ad blocking filters cache
51 --state, -s Purge all state data (includes database and caches)53 --state, -s Purge all state data (includes database and caches)
52 --config Purge configuration files54 --config Purge configuration files
53 --all, -a Purge everything (default if no specific flags are provided)55 --all, -a Purge everything (default if no specific flags are provided)
@@ -57,8 +59,8 @@ Use --force to skip the confirmation prompt.
57 Examples:59 Examples:
58 dumber purge # Purge everything (with confirmation)60 dumber purge # Purge everything (with confirmation)
59 dumber purge --force # Purge everything (no confirmation)61 dumber purge --force # Purge everything (no confirmation)
60- dumber purge -d -H -c # Purge database and both caches62+ dumber purge -d -H -c -F # Purge database and all caches
61- dumber purge --browser-data -f # Force purge browser data only`,63+ dumber purge --filter-cache -f # Force purge filter cache only`,
62 RunE: func(_ *cobra.Command, _ []string) error {64 RunE: func(_ *cobra.Command, _ []string) error {
63 // Initialize CLI to get config paths65 // Initialize CLI to get config paths
64 cli, err := NewCLI()66 cli, err := NewCLI()
@@ -80,6 +82,7 @@ Examples:
80 cmd.Flags().BoolVarP(&flags.HistoryCache, "history-cache", "H", false, "Purge dmenu fuzzy search cache")82 cmd.Flags().BoolVarP(&flags.HistoryCache, "history-cache", "H", false, "Purge dmenu fuzzy search cache")
81 cmd.Flags().BoolVarP(&flags.BrowserCache, "browser-cache", "c", false, "Purge WebKit browser cache")83 cmd.Flags().BoolVarP(&flags.BrowserCache, "browser-cache", "c", false, "Purge WebKit browser cache")
82 cmd.Flags().BoolVarP(&flags.BrowserData, "browser-data", "b", false, "Purge WebKit browser data (cookies, localStorage)")84 cmd.Flags().BoolVarP(&flags.BrowserData, "browser-data", "b", false, "Purge WebKit browser data (cookies, localStorage)")
85+ cmd.Flags().BoolVarP(&flags.FilterCache, "filter-cache", "F", false, "Purge compiled ad blocking filters cache")
83 cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data")86 cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data")
84 cmd.Flags().BoolVar(&flags.Config, "config", false, "Purge configuration files")87 cmd.Flags().BoolVar(&flags.Config, "config", false, "Purge configuration files")
85 cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Purge everything")88 cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Purge everything")
@@ -120,8 +123,8 @@ func determinePurgeItems(flags PurgeFlags) []string {
120 var items []string123 var items []string
121 124
122 // If no specific flags are set, or --all is set, purge everything125 // If no specific flags are set, or --all is set, purge everything
123- if flags.All || (!flags.Database && !flags.HistoryCache && !flags.BrowserCache && !flags.BrowserData && !flags.State && !flags.Config) {126+ if flags.All || (!flags.Database && !flags.HistoryCache && !flags.BrowserCache && !flags.BrowserData && !flags.FilterCache && !flags.State && !flags.Config) {
124- return []string{"database", "history-cache", "browser-cache", "browser-data", "config"}127+ return []string{"database", "history-cache", "browser-cache", "browser-data", "filter-cache", "config"}
125 }128 }
126 129
127 // Add items based on flags130 // Add items based on flags
@@ -137,9 +140,12 @@ func determinePurgeItems(flags PurgeFlags) []string {
137 if flags.BrowserData {140 if flags.BrowserData {
138 items = append(items, "browser-data")141 items = append(items, "browser-data")
139 }142 }
143+ if flags.FilterCache {
144+ items = append(items, "filter-cache")
145+ }
140 if flags.State {146 if flags.State {
141- // State includes database and both caches147+ // State includes database and all caches
142- items = append(items, "database", "history-cache", "browser-cache")148+ items = append(items, "database", "history-cache", "browser-cache", "filter-cache")
143 }149 }
144 if flags.Config {150 if flags.Config {
145 items = append(items, "config")151 items = append(items, "config")
@@ -195,6 +201,13 @@ func getPurgePaths(items []string) (map[string][]string, error) {
195 webkitData := filepath.Join(dataDir, "webkit")201 webkitData := filepath.Join(dataDir, "webkit")
196 paths[item] = []string{webkitData}202 paths[item] = []string{webkitData}
197 203
204+ case "filter-cache":
205+ filterCacheDir, err := config.GetFilterCacheDir()
206+ if err != nil {
207+ return nil, fmt.Errorf("failed to get filter cache directory: %w", err)
208+ }
209+ paths[item] = []string{filterCacheDir}
210+
198 case "config":211 case "config":
199 configFile, err := config.GetConfigFile()212 configFile, err := config.GetConfigFile()
200 if err != nil {213 if err != nil {
M internal/config/config.go +26 -0
@@ -34,6 +34,7 @@ type Config struct {
3434 VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`
3535 CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences"`
3636 WebkitMemory WebkitMemoryConfig `mapstructure:"webkit_memory" yaml:"webkit_memory"`
37+ Debug DebugConfig `mapstructure:"debug" yaml:"debug"`
3738 // RenderingMode controls GPU/CPU rendering selection for WebKit
3839 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
3940 }
@@ -182,6 +183,31 @@ type WebkitMemoryConfig struct {
182183 EnableMemoryMonitoring bool `mapstructure:"enable_memory_monitoring" yaml:"enable_memory_monitoring"`
183184 }
184185
186+// DebugConfig holds debug and troubleshooting options
187+type DebugConfig struct {
188+ // Enable WebKit internal debug logging
189+ EnableWebKitDebug bool `mapstructure:"enable_webkit_debug" yaml:"enable_webkit_debug"`
190+
191+ // WebKit debug categories (comma-separated)
192+ // Common values: "Network:preconnectTo", "ContentFilters", "Loading", "JavaScript"
193+ WebKitDebugCategories string `mapstructure:"webkit_debug_categories" yaml:"webkit_debug_categories"`
194+
195+ // Enable content filtering debug logs
196+ EnableFilteringDebug bool `mapstructure:"enable_filtering_debug" yaml:"enable_filtering_debug"`
197+
198+ // Enable detailed WebView state logging
199+ EnableWebViewDebug bool `mapstructure:"enable_webview_debug" yaml:"enable_webview_debug"`
200+
201+ // Log WebKit crashes and errors to file
202+ LogWebKitCrashes bool `mapstructure:"log_webkit_crashes" yaml:"log_webkit_crashes"`
203+
204+ // Enable script injection debug logs
205+ EnableScriptDebug bool `mapstructure:"enable_script_debug" yaml:"enable_script_debug"`
206+
207+ // Enable general debug mode (equivalent to DUMBER_DEBUG env var)
208+ EnableGeneralDebug bool `mapstructure:"enable_general_debug" yaml:"enable_general_debug"`
209+}
210+
185211 // Manager handles configuration loading, watching, and reloading.
186212 type Manager struct {
187213 config *Config
@@ -34,6 +34,7 @@ type Config struct {
34 VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`34 VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"`
35 CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences"`35 CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences"`
36 WebkitMemory WebkitMemoryConfig `mapstructure:"webkit_memory" yaml:"webkit_memory"`36 WebkitMemory WebkitMemoryConfig `mapstructure:"webkit_memory" yaml:"webkit_memory"`
37+ Debug DebugConfig `mapstructure:"debug" yaml:"debug"`
37 // RenderingMode controls GPU/CPU rendering selection for WebKit38 // RenderingMode controls GPU/CPU rendering selection for WebKit
38 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`39 RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"`
39 }40 }
@@ -182,6 +183,31 @@ type WebkitMemoryConfig struct {
182 EnableMemoryMonitoring bool `mapstructure:"enable_memory_monitoring" yaml:"enable_memory_monitoring"`183 EnableMemoryMonitoring bool `mapstructure:"enable_memory_monitoring" yaml:"enable_memory_monitoring"`
183 }184 }
184 185
186+// DebugConfig holds debug and troubleshooting options
187+type DebugConfig struct {
188+ // Enable WebKit internal debug logging
189+ EnableWebKitDebug bool `mapstructure:"enable_webkit_debug" yaml:"enable_webkit_debug"`
190+
191+ // WebKit debug categories (comma-separated)
192+ // Common values: "Network:preconnectTo", "ContentFilters", "Loading", "JavaScript"
193+ WebKitDebugCategories string `mapstructure:"webkit_debug_categories" yaml:"webkit_debug_categories"`
194+
195+ // Enable content filtering debug logs
196+ EnableFilteringDebug bool `mapstructure:"enable_filtering_debug" yaml:"enable_filtering_debug"`
197+
198+ // Enable detailed WebView state logging
199+ EnableWebViewDebug bool `mapstructure:"enable_webview_debug" yaml:"enable_webview_debug"`
200+
201+ // Log WebKit crashes and errors to file
202+ LogWebKitCrashes bool `mapstructure:"log_webkit_crashes" yaml:"log_webkit_crashes"`
203+
204+ // Enable script injection debug logs
205+ EnableScriptDebug bool `mapstructure:"enable_script_debug" yaml:"enable_script_debug"`
206+
207+ // Enable general debug mode (equivalent to DUMBER_DEBUG env var)
208+ EnableGeneralDebug bool `mapstructure:"enable_general_debug" yaml:"enable_general_debug"`
209+}
210+
185 // Manager handles configuration loading, watching, and reloading.211 // Manager handles configuration loading, watching, and reloading.
186 type Manager struct {212 type Manager struct {
187 config *Config213 config *Config
M internal/config/defaults.go +9 -0
@@ -131,6 +131,15 @@ func DefaultConfig() *Config {
131131 ProcessRecycleThreshold: 50, // Recycle after 50 page loads
132132 EnableMemoryMonitoring: true, // Monitor for production tuning
133133 },
134+ Debug: DebugConfig{
135+ EnableWebKitDebug: false,
136+ WebKitDebugCategories: "Network:preconnectTo,ContentFilters",
137+ EnableFilteringDebug: false,
138+ EnableWebViewDebug: false,
139+ LogWebKitCrashes: true, // Always log crashes
140+ EnableScriptDebug: false,
141+ EnableGeneralDebug: false,
142+ },
134143 RenderingMode: RenderingModeAuto,
135144 }
136145 }
@@ -131,6 +131,15 @@ func DefaultConfig() *Config {
131 ProcessRecycleThreshold: 50, // Recycle after 50 page loads131 ProcessRecycleThreshold: 50, // Recycle after 50 page loads
132 EnableMemoryMonitoring: true, // Monitor for production tuning132 EnableMemoryMonitoring: true, // Monitor for production tuning
133 },133 },
134+ Debug: DebugConfig{
135+ EnableWebKitDebug: false,
136+ WebKitDebugCategories: "Network:preconnectTo,ContentFilters",
137+ EnableFilteringDebug: false,
138+ EnableWebViewDebug: false,
139+ LogWebKitCrashes: true, // Always log crashes
140+ EnableScriptDebug: false,
141+ EnableGeneralDebug: false,
142+ },
134 RenderingMode: RenderingModeAuto,143 RenderingMode: RenderingModeAuto,
135 }144 }
136 }145 }
M internal/config/xdg.go +19 -0
@@ -109,6 +109,25 @@ func GetDatabaseFile() (string, error) {
109109 return filepath.Join(stateDir, "history.db"), nil
110110 }
111111
112+// GetFilterCacheDir returns the XDG-compliant filter cache directory for dumber.
113+// Filter cache is stored in XDG_STATE_HOME as it's transient data that can be regenerated.
114+func GetFilterCacheDir() (string, error) {
115+ stateDir, err := GetStateDir()
116+ if err != nil {
117+ return "", err
118+ }
119+ return filepath.Join(stateDir, "filter-cache"), nil
120+}
121+
122+// GetFilterCacheFile returns the path to the main filter cache file.
123+func GetFilterCacheFile() (string, error) {
124+ cacheDir, err := GetFilterCacheDir()
125+ if err != nil {
126+ return "", err
127+ }
128+ return filepath.Join(cacheDir, "filters.cache"), nil
129+}
130+
112131 // EnsureDirectories creates the XDG directories if they don't exist.
113132 func EnsureDirectories() error {
114133 dirs, err := GetXDGDirs()
@@ -109,6 +109,25 @@ func GetDatabaseFile() (string, error) {
109 return filepath.Join(stateDir, "history.db"), nil109 return filepath.Join(stateDir, "history.db"), nil
110 }110 }
111 111
112+// GetFilterCacheDir returns the XDG-compliant filter cache directory for dumber.
113+// Filter cache is stored in XDG_STATE_HOME as it's transient data that can be regenerated.
114+func GetFilterCacheDir() (string, error) {
115+ stateDir, err := GetStateDir()
116+ if err != nil {
117+ return "", err
118+ }
119+ return filepath.Join(stateDir, "filter-cache"), nil
120+}
121+
122+// GetFilterCacheFile returns the path to the main filter cache file.
123+func GetFilterCacheFile() (string, error) {
124+ cacheDir, err := GetFilterCacheDir()
125+ if err != nil {
126+ return "", err
127+ }
128+ return filepath.Join(cacheDir, "filters.cache"), nil
129+}
130+
112 // EnsureDirectories creates the XDG directories if they don't exist.131 // EnsureDirectories creates the XDG directories if they don't exist.
113 func EnsureDirectories() error {132 func EnsureDirectories() error {
114 dirs, err := GetXDGDirs()133 dirs, err := GetXDGDirs()
A internal/filtering/compiler.go +101 -0
@@ -0,0 +1,101 @@
1+package filtering
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/filtering/converter"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// DefaultFilterCompiler implements FilterCompiler using the converter package
15+type DefaultFilterCompiler struct {
16+ converter *converter.FilterConverter
17+}
18+
19+// NewDefaultFilterCompiler creates a new default filter compiler
20+func NewDefaultFilterCompiler() *DefaultFilterCompiler {
21+ return &DefaultFilterCompiler{
22+ converter: converter.NewFilterConverter(),
23+ }
24+}
25+
26+// CompileFromSources downloads and compiles filters from multiple URLs
27+func (dfc *DefaultFilterCompiler) CompileFromSources(ctx context.Context, sources []string) (*CompiledFilters, error) {
28+ compiled := NewCompiledFilters()
29+
30+ for _, url := range sources {
31+ select {
32+ case <-ctx.Done():
33+ return nil, ctx.Err()
34+ default:
35+ }
36+
37+ filters, err := dfc.downloadAndCompile(ctx, url)
38+ if err != nil {
39+ logging.Error(fmt.Sprintf("Failed to compile from %s: %v", url, err))
40+ continue
41+ }
42+
43+ compiled.Merge(filters)
44+ }
45+
46+ compiled.Version = fmt.Sprintf("compiled-%d", time.Now().Unix())
47+ compiled.CompiledAt = time.Now()
48+
49+ return compiled, nil
50+}
51+
52+// CompileFromData compiles filters from raw data bytes
53+func (dfc *DefaultFilterCompiler) CompileFromData(data []byte) (*CompiledFilters, error) {
54+ compiled := NewCompiledFilters()
55+
56+ // Reset converter for new compilation
57+ dfc.converter = converter.NewFilterConverter()
58+
59+ // Parse line by line
60+ scanner := bufio.NewScanner(strings.NewReader(string(data)))
61+ lineCount := 0
62+
63+ for scanner.Scan() {
64+ line := scanner.Text()
65+ lineCount++
66+
67+ // Convert each line
68+ if err := dfc.converter.ConvertEasyListLine(line); err != nil {
69+ logging.Debug(fmt.Sprintf("Failed to convert filter line: %s, error: %v", line, err))
70+ continue
71+ }
72+ }
73+
74+ if err := scanner.Err(); err != nil {
75+ return nil, fmt.Errorf("error reading filter data: %w", err)
76+ }
77+
78+ // Get compiled rules from converter
79+ compiled.NetworkRules = dfc.converter.GetNetworkRules()
80+ compiled.CosmeticRules = dfc.converter.GetCosmeticRules()
81+ compiled.GenericHiding = dfc.converter.GetGenericHiding()
82+ compiled.CompiledAt = time.Now()
83+ compiled.Version = fmt.Sprintf("data-compile-%d", time.Now().Unix())
84+
85+ logging.Info(fmt.Sprintf("Compiled %d filter lines into %d network rules and %d cosmetic rules",
86+ lineCount, len(compiled.NetworkRules), len(compiled.CosmeticRules)))
87+
88+ return compiled, nil
89+}
90+
91+// downloadAndCompile downloads and compiles a single filter list
92+func (dfc *DefaultFilterCompiler) downloadAndCompile(ctx context.Context, url string) (*CompiledFilters, error) {
93+ // This would implement HTTP download - for now, return empty filters
94+ logging.Debug(fmt.Sprintf("Would download and compile from %s", url))
95+
96+ compiled := NewCompiledFilters()
97+ compiled.Version = fmt.Sprintf("stub-%d", time.Now().Unix())
98+ compiled.CompiledAt = time.Now()
99+
100+ return compiled, nil
101+}
@@ -0,0 +1,101 @@
1+package filtering
2+
3+import (
4+ "bufio"
5+ "context"
6+ "fmt"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/filtering/converter"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+// DefaultFilterCompiler implements FilterCompiler using the converter package
15+type DefaultFilterCompiler struct {
16+ converter *converter.FilterConverter
17+}
18+
19+// NewDefaultFilterCompiler creates a new default filter compiler
20+func NewDefaultFilterCompiler() *DefaultFilterCompiler {
21+ return &DefaultFilterCompiler{
22+ converter: converter.NewFilterConverter(),
23+ }
24+}
25+
26+// CompileFromSources downloads and compiles filters from multiple URLs
27+func (dfc *DefaultFilterCompiler) CompileFromSources(ctx context.Context, sources []string) (*CompiledFilters, error) {
28+ compiled := NewCompiledFilters()
29+
30+ for _, url := range sources {
31+ select {
32+ case <-ctx.Done():
33+ return nil, ctx.Err()
34+ default:
35+ }
36+
37+ filters, err := dfc.downloadAndCompile(ctx, url)
38+ if err != nil {
39+ logging.Error(fmt.Sprintf("Failed to compile from %s: %v", url, err))
40+ continue
41+ }
42+
43+ compiled.Merge(filters)
44+ }
45+
46+ compiled.Version = fmt.Sprintf("compiled-%d", time.Now().Unix())
47+ compiled.CompiledAt = time.Now()
48+
49+ return compiled, nil
50+}
51+
52+// CompileFromData compiles filters from raw data bytes
53+func (dfc *DefaultFilterCompiler) CompileFromData(data []byte) (*CompiledFilters, error) {
54+ compiled := NewCompiledFilters()
55+
56+ // Reset converter for new compilation
57+ dfc.converter = converter.NewFilterConverter()
58+
59+ // Parse line by line
60+ scanner := bufio.NewScanner(strings.NewReader(string(data)))
61+ lineCount := 0
62+
63+ for scanner.Scan() {
64+ line := scanner.Text()
65+ lineCount++
66+
67+ // Convert each line
68+ if err := dfc.converter.ConvertEasyListLine(line); err != nil {
69+ logging.Debug(fmt.Sprintf("Failed to convert filter line: %s, error: %v", line, err))
70+ continue
71+ }
72+ }
73+
74+ if err := scanner.Err(); err != nil {
75+ return nil, fmt.Errorf("error reading filter data: %w", err)
76+ }
77+
78+ // Get compiled rules from converter
79+ compiled.NetworkRules = dfc.converter.GetNetworkRules()
80+ compiled.CosmeticRules = dfc.converter.GetCosmeticRules()
81+ compiled.GenericHiding = dfc.converter.GetGenericHiding()
82+ compiled.CompiledAt = time.Now()
83+ compiled.Version = fmt.Sprintf("data-compile-%d", time.Now().Unix())
84+
85+ logging.Info(fmt.Sprintf("Compiled %d filter lines into %d network rules and %d cosmetic rules",
86+ lineCount, len(compiled.NetworkRules), len(compiled.CosmeticRules)))
87+
88+ return compiled, nil
89+}
90+
91+// downloadAndCompile downloads and compiles a single filter list
92+func (dfc *DefaultFilterCompiler) downloadAndCompile(ctx context.Context, url string) (*CompiledFilters, error) {
93+ // This would implement HTTP download - for now, return empty filters
94+ logging.Debug(fmt.Sprintf("Would download and compile from %s", url))
95+
96+ compiled := NewCompiledFilters()
97+ compiled.Version = fmt.Sprintf("stub-%d", time.Now().Unix())
98+ compiled.CompiledAt = time.Now()
99+
100+ return compiled, nil
101+}
A internal/filtering/converter/easylist_to_webkit.go +252 -0
@@ -0,0 +1,252 @@
1+package converter
2+
3+import (
4+ "encoding/json"
5+ "regexp"
6+ "strings"
7+)
8+
9+const (
10+ cosmeticFilterParts = 2 // Number of parts when splitting cosmetic filter by ##
11+)
12+
13+// FilterConverter converts EasyList filters to WebKit format
14+type FilterConverter struct {
15+ networkRules []WebKitRule
16+ cosmeticRules map[string][]string // domain -> selectors
17+ genericHiding []string // ##selector rules
18+}
19+
20+// NewFilterConverter creates a new filter converter
21+func NewFilterConverter() *FilterConverter {
22+ return &FilterConverter{
23+ networkRules: make([]WebKitRule, 0),
24+ cosmeticRules: make(map[string][]string),
25+ genericHiding: make([]string, 0),
26+ }
27+}
28+
29+// isASCII checks if string contains only ASCII characters
30+func isASCII(s string) bool {
31+ for _, r := range s {
32+ if r > 127 {
33+ return false
34+ }
35+ }
36+ return true
37+}
38+
39+// ConvertEasyListLine converts a single EasyList line to WebKit format
40+func (fc *FilterConverter) ConvertEasyListLine(line string) error {
41+ line = strings.TrimSpace(line)
42+
43+ // Skip comments and empty lines
44+ if line == "" || strings.HasPrefix(line, "!") {
45+ return nil
46+ }
47+
48+ // Skip metadata lines like [Adblock Plus 2.0]
49+ if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
50+ return nil
51+ }
52+
53+ // Skip lines with non-ASCII characters (WebKit doesn't support them in regex)
54+ if !isASCII(line) {
55+ return nil
56+ }
57+
58+ // Cosmetic filters (element hiding)
59+ if strings.Contains(line, "##") {
60+ return fc.parseCosmeticFilter(line)
61+ }
62+
63+ // Network filters
64+ return fc.parseNetworkFilter(line)
65+}
66+
67+func (fc *FilterConverter) parseCosmeticFilter(line string) error {
68+ parts := strings.SplitN(line, "##", cosmeticFilterParts)
69+ if len(parts) != cosmeticFilterParts {
70+ return nil
71+ }
72+
73+ domain := parts[0]
74+ selector := parts[1]
75+
76+ // Handle :has(), :not(), :nth-child() pseudo-selectors
77+ selector = fc.convertAdvancedSelectors(selector)
78+
79+ if domain == "" {
80+ // Generic hiding rule
81+ fc.genericHiding = append(fc.genericHiding, selector)
82+ } else {
83+ // Domain-specific rule
84+ domains := strings.Split(domain, ",")
85+ for _, d := range domains {
86+ d = strings.TrimSpace(d)
87+ if strings.HasPrefix(d, "~") {
88+ // Exception domain - handle separately
89+ continue
90+ }
91+ fc.cosmeticRules[d] = append(fc.cosmeticRules[d], selector)
92+ }
93+ }
94+ return nil
95+}
96+
97+func (fc *FilterConverter) parseNetworkFilter(line string) error {
98+ // Handle exception rules (@@)
99+ isException := false
100+ if strings.HasPrefix(line, "@@") {
101+ isException = true
102+ line = line[2:] // Remove @@
103+ }
104+
105+ // Remove options (everything after $)
106+ filterPart := line
107+ var options []string
108+
109+ if idx := strings.Index(line, "$"); idx != -1 {
110+ filterPart = line[:idx]
111+ optStr := line[idx+1:]
112+ options = strings.Split(optStr, ",")
113+ }
114+
115+ // Skip empty filter parts after processing
116+ if strings.TrimSpace(filterPart) == "" {
117+ return nil
118+ }
119+
120+ // Convert to WebKit regex
121+ actionType := ActionTypeBlock
122+ if isException {
123+ actionType = ActionTypeIgnorePreviousRules
124+ }
125+
126+ rule := WebKitRule{
127+ Trigger: Trigger{
128+ URLFilter: fc.convertToRegex(filterPart),
129+ },
130+ Action: Action{
131+ Type: actionType,
132+ },
133+ }
134+
135+ // Parse options
136+ for _, opt := range options {
137+ opt = strings.TrimSpace(opt)
138+ switch {
139+ case opt == "third-party":
140+ rule.Trigger.LoadType = []string{LoadTypeThirdParty}
141+ case opt == "~third-party":
142+ rule.Trigger.LoadType = []string{LoadTypeFirstParty}
143+ case strings.HasPrefix(opt, "domain="):
144+ domains := strings.Split(opt[7:], "|")
145+ var ifDomains, unlessDomains []string
146+ for _, d := range domains {
147+ if strings.HasPrefix(d, "~") {
148+ unlessDomains = append(unlessDomains, d[1:])
149+ } else {
150+ ifDomains = append(ifDomains, d)
151+ }
152+ }
153+ if len(ifDomains) > 0 {
154+ rule.Trigger.IfDomain = ifDomains
155+ }
156+ if len(unlessDomains) > 0 {
157+ rule.Trigger.UnlessDomain = unlessDomains
158+ }
159+ case opt == "script":
160+ rule.Trigger.ResourceType = []string{ResourceTypeScript}
161+ case opt == "image":
162+ rule.Trigger.ResourceType = []string{ResourceTypeImage}
163+ case opt == "stylesheet":
164+ rule.Trigger.ResourceType = []string{ResourceTypeStyleSheet}
165+ case opt == "font":
166+ rule.Trigger.ResourceType = []string{ResourceTypeFont}
167+ case opt == "media":
168+ rule.Trigger.ResourceType = []string{ResourceTypeMedia}
169+ case opt == "document":
170+ rule.Trigger.ResourceType = []string{ResourceTypeDocument}
171+ case opt == "popup":
172+ rule.Trigger.ResourceType = []string{ResourceTypePopup}
173+ }
174+ }
175+
176+ fc.networkRules = append(fc.networkRules, rule)
177+ return nil
178+}
179+
180+func (fc *FilterConverter) convertToRegex(pattern string) string {
181+ // Handle anchors BEFORE escaping special characters
182+ hasStartAnchor := strings.HasPrefix(pattern, "||")
183+ hasEndAnchor := strings.HasSuffix(pattern, "|") && !strings.HasSuffix(pattern, "||")
184+ hasPipeStart := strings.HasPrefix(pattern, "|") && !strings.HasPrefix(pattern, "||")
185+
186+ // Remove anchors from pattern
187+ if hasStartAnchor {
188+ pattern = pattern[2:]
189+ } else if hasPipeStart {
190+ pattern = pattern[1:]
191+ }
192+
193+ if hasEndAnchor {
194+ pattern = pattern[:len(pattern)-1]
195+ }
196+
197+ // Now escape special characters
198+ pattern = regexp.QuoteMeta(pattern)
199+
200+ // Convert wildcards after escaping
201+ pattern = strings.ReplaceAll(pattern, `\*`, ".*")
202+ pattern = strings.ReplaceAll(pattern, `\^`, "[^a-zA-Z0-9_.%-]")
203+
204+ // Apply anchors to regex
205+ if hasStartAnchor {
206+ // || means start of domain - match protocol and optional subdomain
207+ pattern = "^https?://([^/]*\\.)?" + pattern
208+ } else if hasPipeStart {
209+ // | means start of URL
210+ pattern = "^" + pattern
211+ }
212+
213+ if hasEndAnchor {
214+ // | means end of URL
215+ pattern = pattern + "$"
216+ }
217+
218+ return pattern
219+}
220+
221+func (fc *FilterConverter) convertAdvancedSelectors(selector string) string {
222+ // For now, return as-is. Advanced selector conversion would be more complex
223+ // and would require parsing CSS selectors
224+ return selector
225+}
226+
227+// GetNetworkRules returns compiled network blocking rules
228+func (fc *FilterConverter) GetNetworkRules() []WebKitRule {
229+ return fc.networkRules
230+}
231+
232+// GetCosmeticRules returns cosmetic filtering rules organized by domain
233+func (fc *FilterConverter) GetCosmeticRules() map[string][]string {
234+ return fc.cosmeticRules
235+}
236+
237+// GetGenericHiding returns generic cosmetic hiding rules
238+func (fc *FilterConverter) GetGenericHiding() []string {
239+ return fc.genericHiding
240+}
241+
242+// ToJSON converts network rules to JSON format for WebKit
243+func (fc *FilterConverter) ToJSON() ([]byte, error) {
244+ return json.Marshal(fc.networkRules)
245+}
246+
247+// Reset clears all rules from the converter
248+func (fc *FilterConverter) Reset() {
249+ fc.networkRules = make([]WebKitRule, 0)
250+ fc.cosmeticRules = make(map[string][]string)
251+ fc.genericHiding = make([]string, 0)
252+}
@@ -0,0 +1,252 @@
1+package converter
2+
3+import (
4+ "encoding/json"
5+ "regexp"
6+ "strings"
7+)
8+
9+const (
10+ cosmeticFilterParts = 2 // Number of parts when splitting cosmetic filter by ##
11+)
12+
13+// FilterConverter converts EasyList filters to WebKit format
14+type FilterConverter struct {
15+ networkRules []WebKitRule
16+ cosmeticRules map[string][]string // domain -> selectors
17+ genericHiding []string // ##selector rules
18+}
19+
20+// NewFilterConverter creates a new filter converter
21+func NewFilterConverter() *FilterConverter {
22+ return &FilterConverter{
23+ networkRules: make([]WebKitRule, 0),
24+ cosmeticRules: make(map[string][]string),
25+ genericHiding: make([]string, 0),
26+ }
27+}
28+
29+// isASCII checks if string contains only ASCII characters
30+func isASCII(s string) bool {
31+ for _, r := range s {
32+ if r > 127 {
33+ return false
34+ }
35+ }
36+ return true
37+}
38+
39+// ConvertEasyListLine converts a single EasyList line to WebKit format
40+func (fc *FilterConverter) ConvertEasyListLine(line string) error {
41+ line = strings.TrimSpace(line)
42+
43+ // Skip comments and empty lines
44+ if line == "" || strings.HasPrefix(line, "!") {
45+ return nil
46+ }
47+
48+ // Skip metadata lines like [Adblock Plus 2.0]
49+ if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
50+ return nil
51+ }
52+
53+ // Skip lines with non-ASCII characters (WebKit doesn't support them in regex)
54+ if !isASCII(line) {
55+ return nil
56+ }
57+
58+ // Cosmetic filters (element hiding)
59+ if strings.Contains(line, "##") {
60+ return fc.parseCosmeticFilter(line)
61+ }
62+
63+ // Network filters
64+ return fc.parseNetworkFilter(line)
65+}
66+
67+func (fc *FilterConverter) parseCosmeticFilter(line string) error {
68+ parts := strings.SplitN(line, "##", cosmeticFilterParts)
69+ if len(parts) != cosmeticFilterParts {
70+ return nil
71+ }
72+
73+ domain := parts[0]
74+ selector := parts[1]
75+
76+ // Handle :has(), :not(), :nth-child() pseudo-selectors
77+ selector = fc.convertAdvancedSelectors(selector)
78+
79+ if domain == "" {
80+ // Generic hiding rule
81+ fc.genericHiding = append(fc.genericHiding, selector)
82+ } else {
83+ // Domain-specific rule
84+ domains := strings.Split(domain, ",")
85+ for _, d := range domains {
86+ d = strings.TrimSpace(d)
87+ if strings.HasPrefix(d, "~") {
88+ // Exception domain - handle separately
89+ continue
90+ }
91+ fc.cosmeticRules[d] = append(fc.cosmeticRules[d], selector)
92+ }
93+ }
94+ return nil
95+}
96+
97+func (fc *FilterConverter) parseNetworkFilter(line string) error {
98+ // Handle exception rules (@@)
99+ isException := false
100+ if strings.HasPrefix(line, "@@") {
101+ isException = true
102+ line = line[2:] // Remove @@
103+ }
104+
105+ // Remove options (everything after $)
106+ filterPart := line
107+ var options []string
108+
109+ if idx := strings.Index(line, "$"); idx != -1 {
110+ filterPart = line[:idx]
111+ optStr := line[idx+1:]
112+ options = strings.Split(optStr, ",")
113+ }
114+
115+ // Skip empty filter parts after processing
116+ if strings.TrimSpace(filterPart) == "" {
117+ return nil
118+ }
119+
120+ // Convert to WebKit regex
121+ actionType := ActionTypeBlock
122+ if isException {
123+ actionType = ActionTypeIgnorePreviousRules
124+ }
125+
126+ rule := WebKitRule{
127+ Trigger: Trigger{
128+ URLFilter: fc.convertToRegex(filterPart),
129+ },
130+ Action: Action{
131+ Type: actionType,
132+ },
133+ }
134+
135+ // Parse options
136+ for _, opt := range options {
137+ opt = strings.TrimSpace(opt)
138+ switch {
139+ case opt == "third-party":
140+ rule.Trigger.LoadType = []string{LoadTypeThirdParty}
141+ case opt == "~third-party":
142+ rule.Trigger.LoadType = []string{LoadTypeFirstParty}
143+ case strings.HasPrefix(opt, "domain="):
144+ domains := strings.Split(opt[7:], "|")
145+ var ifDomains, unlessDomains []string
146+ for _, d := range domains {
147+ if strings.HasPrefix(d, "~") {
148+ unlessDomains = append(unlessDomains, d[1:])
149+ } else {
150+ ifDomains = append(ifDomains, d)
151+ }
152+ }
153+ if len(ifDomains) > 0 {
154+ rule.Trigger.IfDomain = ifDomains
155+ }
156+ if len(unlessDomains) > 0 {
157+ rule.Trigger.UnlessDomain = unlessDomains
158+ }
159+ case opt == "script":
160+ rule.Trigger.ResourceType = []string{ResourceTypeScript}
161+ case opt == "image":
162+ rule.Trigger.ResourceType = []string{ResourceTypeImage}
163+ case opt == "stylesheet":
164+ rule.Trigger.ResourceType = []string{ResourceTypeStyleSheet}
165+ case opt == "font":
166+ rule.Trigger.ResourceType = []string{ResourceTypeFont}
167+ case opt == "media":
168+ rule.Trigger.ResourceType = []string{ResourceTypeMedia}
169+ case opt == "document":
170+ rule.Trigger.ResourceType = []string{ResourceTypeDocument}
171+ case opt == "popup":
172+ rule.Trigger.ResourceType = []string{ResourceTypePopup}
173+ }
174+ }
175+
176+ fc.networkRules = append(fc.networkRules, rule)
177+ return nil
178+}
179+
180+func (fc *FilterConverter) convertToRegex(pattern string) string {
181+ // Handle anchors BEFORE escaping special characters
182+ hasStartAnchor := strings.HasPrefix(pattern, "||")
183+ hasEndAnchor := strings.HasSuffix(pattern, "|") && !strings.HasSuffix(pattern, "||")
184+ hasPipeStart := strings.HasPrefix(pattern, "|") && !strings.HasPrefix(pattern, "||")
185+
186+ // Remove anchors from pattern
187+ if hasStartAnchor {
188+ pattern = pattern[2:]
189+ } else if hasPipeStart {
190+ pattern = pattern[1:]
191+ }
192+
193+ if hasEndAnchor {
194+ pattern = pattern[:len(pattern)-1]
195+ }
196+
197+ // Now escape special characters
198+ pattern = regexp.QuoteMeta(pattern)
199+
200+ // Convert wildcards after escaping
201+ pattern = strings.ReplaceAll(pattern, `\*`, ".*")
202+ pattern = strings.ReplaceAll(pattern, `\^`, "[^a-zA-Z0-9_.%-]")
203+
204+ // Apply anchors to regex
205+ if hasStartAnchor {
206+ // || means start of domain - match protocol and optional subdomain
207+ pattern = "^https?://([^/]*\\.)?" + pattern
208+ } else if hasPipeStart {
209+ // | means start of URL
210+ pattern = "^" + pattern
211+ }
212+
213+ if hasEndAnchor {
214+ // | means end of URL
215+ pattern = pattern + "$"
216+ }
217+
218+ return pattern
219+}
220+
221+func (fc *FilterConverter) convertAdvancedSelectors(selector string) string {
222+ // For now, return as-is. Advanced selector conversion would be more complex
223+ // and would require parsing CSS selectors
224+ return selector
225+}
226+
227+// GetNetworkRules returns compiled network blocking rules
228+func (fc *FilterConverter) GetNetworkRules() []WebKitRule {
229+ return fc.networkRules
230+}
231+
232+// GetCosmeticRules returns cosmetic filtering rules organized by domain
233+func (fc *FilterConverter) GetCosmeticRules() map[string][]string {
234+ return fc.cosmeticRules
235+}
236+
237+// GetGenericHiding returns generic cosmetic hiding rules
238+func (fc *FilterConverter) GetGenericHiding() []string {
239+ return fc.genericHiding
240+}
241+
242+// ToJSON converts network rules to JSON format for WebKit
243+func (fc *FilterConverter) ToJSON() ([]byte, error) {
244+ return json.Marshal(fc.networkRules)
245+}
246+
247+// Reset clears all rules from the converter
248+func (fc *FilterConverter) Reset() {
249+ fc.networkRules = make([]WebKitRule, 0)
250+ fc.cosmeticRules = make(map[string][]string)
251+ fc.genericHiding = make([]string, 0)
252+}
A internal/filtering/converter/webkit_types.go +52 -0
@@ -0,0 +1,52 @@
1+// Package converter provides types and functions for converting EasyList filters to WebKit content blocking format.
2+package converter
3+
4+// WebKitRule represents a WebKit content blocking rule
5+type WebKitRule struct {
6+ Trigger Trigger `json:"trigger"`
7+ Action Action `json:"action"`
8+}
9+
10+// Trigger defines when a rule should be applied
11+type Trigger struct {
12+ URLFilter string `json:"url-filter"`
13+ URLFilterIsCaseSensitive *bool `json:"url-filter-is-case-sensitive,omitempty"`
14+ IfDomain []string `json:"if-domain,omitempty"`
15+ UnlessDomain []string `json:"unless-domain,omitempty"`
16+ IfTopURL []string `json:"if-top-url,omitempty"`
17+ UnlessTopURL []string `json:"unless-top-url,omitempty"`
18+ ResourceType []string `json:"resource-type,omitempty"`
19+ LoadType []string `json:"load-type,omitempty"`
20+}
21+
22+// Action defines what to do when a rule matches
23+type Action struct {
24+ Type string `json:"type"`
25+ Selector string `json:"selector,omitempty"`
26+}
27+
28+// ActionType constants for WebKit actions
29+const (
30+ ActionTypeBlock = "block"
31+ ActionTypeIgnorePreviousRules = "ignore-previous-rules"
32+ ActionTypeBlockCookies = "block-cookies"
33+ ActionTypeCSSDisplayNone = "css-display-none"
34+)
35+
36+// ResourceType constants for WebKit resource types
37+const (
38+ ResourceTypeScript = "script"
39+ ResourceTypeImage = "image"
40+ ResourceTypeStyleSheet = "style-sheet"
41+ ResourceTypeFont = "font"
42+ ResourceTypeMedia = "media"
43+ ResourceTypeSVGDocument = "svg-document"
44+ ResourceTypeDocument = "document"
45+ ResourceTypePopup = "popup"
46+)
47+
48+// LoadType constants for WebKit load types
49+const (
50+ LoadTypeFirstParty = "first-party"
51+ LoadTypeThirdParty = "third-party"
52+)
@@ -0,0 +1,52 @@
1+// Package converter provides types and functions for converting EasyList filters to WebKit content blocking format.
2+package converter
3+
4+// WebKitRule represents a WebKit content blocking rule
5+type WebKitRule struct {
6+ Trigger Trigger `json:"trigger"`
7+ Action Action `json:"action"`
8+}
9+
10+// Trigger defines when a rule should be applied
11+type Trigger struct {
12+ URLFilter string `json:"url-filter"`
13+ URLFilterIsCaseSensitive *bool `json:"url-filter-is-case-sensitive,omitempty"`
14+ IfDomain []string `json:"if-domain,omitempty"`
15+ UnlessDomain []string `json:"unless-domain,omitempty"`
16+ IfTopURL []string `json:"if-top-url,omitempty"`
17+ UnlessTopURL []string `json:"unless-top-url,omitempty"`
18+ ResourceType []string `json:"resource-type,omitempty"`
19+ LoadType []string `json:"load-type,omitempty"`
20+}
21+
22+// Action defines what to do when a rule matches
23+type Action struct {
24+ Type string `json:"type"`
25+ Selector string `json:"selector,omitempty"`
26+}
27+
28+// ActionType constants for WebKit actions
29+const (
30+ ActionTypeBlock = "block"
31+ ActionTypeIgnorePreviousRules = "ignore-previous-rules"
32+ ActionTypeBlockCookies = "block-cookies"
33+ ActionTypeCSSDisplayNone = "css-display-none"
34+)
35+
36+// ResourceType constants for WebKit resource types
37+const (
38+ ResourceTypeScript = "script"
39+ ResourceTypeImage = "image"
40+ ResourceTypeStyleSheet = "style-sheet"
41+ ResourceTypeFont = "font"
42+ ResourceTypeMedia = "media"
43+ ResourceTypeSVGDocument = "svg-document"
44+ ResourceTypeDocument = "document"
45+ ResourceTypePopup = "popup"
46+)
47+
48+// LoadType constants for WebKit load types
49+const (
50+ LoadTypeFirstParty = "first-party"
51+ LoadTypeThirdParty = "third-party"
52+)
A internal/filtering/cosmetic/injector.go +223 -0
@@ -0,0 +1,223 @@
1+package cosmetic
2+
3+import (
4+ _ "embed"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+//go:embed injector.js
13+var cosmeticScript string
14+
15+// CosmeticInjector manages cosmetic filter injection
16+type CosmeticInjector struct {
17+ domainRules map[string][]string
18+ genericRules []string
19+ scriptEnabled bool
20+}
21+
22+// CosmeticRule represents a cosmetic filter rule for JavaScript injection
23+type CosmeticRule struct {
24+ Domain string `json:"domain,omitempty"`
25+ Selector string `json:"selector"`
26+}
27+
28+// NewCosmeticInjector creates a new cosmetic filter injector
29+func NewCosmeticInjector() *CosmeticInjector {
30+ return &CosmeticInjector{
31+ domainRules: make(map[string][]string),
32+ genericRules: make([]string, 0),
33+ scriptEnabled: true,
34+ }
35+}
36+
37+// InjectRules stores cosmetic rules for domain-specific filtering
38+func (ci *CosmeticInjector) InjectRules(rules map[string][]string) error {
39+ if !ci.scriptEnabled {
40+ return fmt.Errorf("cosmetic filtering disabled")
41+ }
42+
43+ // Clear existing rules
44+ ci.domainRules = make(map[string][]string)
45+ ci.genericRules = make([]string, 0)
46+
47+ // Process rules
48+ for domain, selectors := range rules {
49+ if domain == "" {
50+ // Generic rules (apply to all domains)
51+ ci.genericRules = append(ci.genericRules, selectors...)
52+ } else {
53+ // Domain-specific rules
54+ ci.domainRules[domain] = selectors
55+ }
56+ }
57+
58+ logging.Debug(fmt.Sprintf("Injected %d domain-specific rules and %d generic rules",
59+ len(ci.domainRules), len(ci.genericRules)))
60+
61+ return nil
62+}
63+
64+// GetScriptForDomain returns the complete cosmetic filtering script for a domain
65+func (ci *CosmeticInjector) GetScriptForDomain(domain string) string {
66+ if !ci.scriptEnabled {
67+ return ""
68+ }
69+
70+ // Build rules for this specific domain
71+ var rules []CosmeticRule
72+
73+ // Add generic rules
74+ for _, selector := range ci.genericRules {
75+ rules = append(rules, CosmeticRule{
76+ Selector: selector,
77+ })
78+ }
79+
80+ // Add domain-specific rules
81+ if domainSelectors, exists := ci.domainRules[domain]; exists {
82+ for _, selector := range domainSelectors {
83+ rules = append(rules, CosmeticRule{
84+ Domain: domain,
85+ Selector: selector,
86+ })
87+ }
88+ }
89+
90+ // Convert rules to JSON
91+ rulesJSON, err := json.Marshal(rules)
92+ if err != nil {
93+ logging.Error(fmt.Sprintf("Failed to marshal cosmetic rules: %v", err))
94+ return cosmeticScript
95+ }
96+
97+ // Build the complete script with domain-specific rules
98+ initScript := fmt.Sprintf(`
99+// Initialize cosmetic filtering for domain: %s
100+(function() {
101+ if (typeof window.__dumber_cosmetic_init === 'function') {
102+ window.__dumber_cosmetic_init(%s);
103+ console.log('[dumber] Cosmetic filtering initialized with %d rules');
104+ } else {
105+ console.warn('[dumber] Cosmetic filtering not available');
106+ }
107+})();`, domain, string(rulesJSON), len(rules))
108+
109+ return cosmeticScript + "\n" + initScript
110+}
111+
112+// UpdateRulesForDomain adds new rules for a specific domain
113+func (ci *CosmeticInjector) UpdateRulesForDomain(domain string, newSelectors []string) {
114+ if !ci.scriptEnabled {
115+ return
116+ }
117+
118+ if len(newSelectors) == 0 {
119+ return
120+ }
121+
122+ if domain == "" {
123+ // Add to generic rules
124+ ci.genericRules = append(ci.genericRules, newSelectors...)
125+ } else {
126+ // Add to domain-specific rules
127+ if _, exists := ci.domainRules[domain]; !exists {
128+ ci.domainRules[domain] = make([]string, 0)
129+ }
130+ ci.domainRules[domain] = append(ci.domainRules[domain], newSelectors...)
131+ }
132+
133+ logging.Debug(fmt.Sprintf("Added %d new cosmetic rules for domain: %s", len(newSelectors), domain))
134+}
135+
136+// GetUpdateScript returns JavaScript to update rules dynamically
137+func (ci *CosmeticInjector) GetUpdateScript(newSelectors []string) string {
138+ if !ci.scriptEnabled || len(newSelectors) == 0 {
139+ return ""
140+ }
141+
142+ selectorsJSON, err := json.Marshal(newSelectors)
143+ if err != nil {
144+ logging.Error(fmt.Sprintf("Failed to marshal update selectors: %v", err))
145+ return ""
146+ }
147+
148+ return fmt.Sprintf(`
149+(function() {
150+ if (typeof window.__dumber_cosmetic_update === 'function') {
151+ window.__dumber_cosmetic_update(%s);
152+ console.log('[dumber] Updated cosmetic rules with %d new selectors');
153+ }
154+})();`, string(selectorsJSON), len(newSelectors))
155+}
156+
157+// GetCleanupScript returns JavaScript to cleanup cosmetic filtering on navigation
158+func (ci *CosmeticInjector) GetCleanupScript() string {
159+ return `
160+(function() {
161+ if (typeof window.__dumber_cosmetic_cleanup === 'function') {
162+ window.__dumber_cosmetic_cleanup();
163+ console.log('[dumber] Cosmetic filtering cleaned up');
164+ }
165+})();`
166+}
167+
168+// GetRulesForDomain returns all cosmetic rules that apply to a domain
169+func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string {
170+ var rules []string
171+
172+ // Add generic rules
173+ rules = append(rules, ci.genericRules...)
174+
175+ // Add domain-specific rules
176+ if domainSelectors, exists := ci.domainRules[domain]; exists {
177+ rules = append(rules, domainSelectors...)
178+ }
179+
180+ // Also check for subdomain matches
181+ domainParts := strings.Split(domain, ".")
182+ if len(domainParts) > 2 {
183+ // Try parent domain (e.g., "example.com" for "www.example.com")
184+ parentDomain := strings.Join(domainParts[1:], ".")
185+ if parentSelectors, exists := ci.domainRules[parentDomain]; exists {
186+ rules = append(rules, parentSelectors...)
187+ }
188+ }
189+
190+ return rules
191+}
192+
193+// Enable enables or disables cosmetic filtering
194+func (ci *CosmeticInjector) Enable(enabled bool) {
195+ ci.scriptEnabled = enabled
196+ if enabled {
197+ logging.Info("Cosmetic filtering enabled")
198+ } else {
199+ logging.Info("Cosmetic filtering disabled")
200+ }
201+}
202+
203+// IsEnabled returns whether cosmetic filtering is enabled
204+func (ci *CosmeticInjector) IsEnabled() bool {
205+ return ci.scriptEnabled
206+}
207+
208+// GetStats returns statistics about loaded cosmetic rules
209+func (ci *CosmeticInjector) GetStats() map[string]interface{} {
210+ domainCount := len(ci.domainRules)
211+ totalRules := len(ci.genericRules)
212+
213+ for _, selectors := range ci.domainRules {
214+ totalRules += len(selectors)
215+ }
216+
217+ return map[string]interface{}{
218+ "enabled": ci.scriptEnabled,
219+ "generic_rules": len(ci.genericRules),
220+ "domain_count": domainCount,
221+ "total_rules": totalRules,
222+ }
223+}
@@ -0,0 +1,223 @@
1+package cosmetic
2+
3+import (
4+ _ "embed"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+//go:embed injector.js
13+var cosmeticScript string
14+
15+// CosmeticInjector manages cosmetic filter injection
16+type CosmeticInjector struct {
17+ domainRules map[string][]string
18+ genericRules []string
19+ scriptEnabled bool
20+}
21+
22+// CosmeticRule represents a cosmetic filter rule for JavaScript injection
23+type CosmeticRule struct {
24+ Domain string `json:"domain,omitempty"`
25+ Selector string `json:"selector"`
26+}
27+
28+// NewCosmeticInjector creates a new cosmetic filter injector
29+func NewCosmeticInjector() *CosmeticInjector {
30+ return &CosmeticInjector{
31+ domainRules: make(map[string][]string),
32+ genericRules: make([]string, 0),
33+ scriptEnabled: true,
34+ }
35+}
36+
37+// InjectRules stores cosmetic rules for domain-specific filtering
38+func (ci *CosmeticInjector) InjectRules(rules map[string][]string) error {
39+ if !ci.scriptEnabled {
40+ return fmt.Errorf("cosmetic filtering disabled")
41+ }
42+
43+ // Clear existing rules
44+ ci.domainRules = make(map[string][]string)
45+ ci.genericRules = make([]string, 0)
46+
47+ // Process rules
48+ for domain, selectors := range rules {
49+ if domain == "" {
50+ // Generic rules (apply to all domains)
51+ ci.genericRules = append(ci.genericRules, selectors...)
52+ } else {
53+ // Domain-specific rules
54+ ci.domainRules[domain] = selectors
55+ }
56+ }
57+
58+ logging.Debug(fmt.Sprintf("Injected %d domain-specific rules and %d generic rules",
59+ len(ci.domainRules), len(ci.genericRules)))
60+
61+ return nil
62+}
63+
64+// GetScriptForDomain returns the complete cosmetic filtering script for a domain
65+func (ci *CosmeticInjector) GetScriptForDomain(domain string) string {
66+ if !ci.scriptEnabled {
67+ return ""
68+ }
69+
70+ // Build rules for this specific domain
71+ var rules []CosmeticRule
72+
73+ // Add generic rules
74+ for _, selector := range ci.genericRules {
75+ rules = append(rules, CosmeticRule{
76+ Selector: selector,
77+ })
78+ }
79+
80+ // Add domain-specific rules
81+ if domainSelectors, exists := ci.domainRules[domain]; exists {
82+ for _, selector := range domainSelectors {
83+ rules = append(rules, CosmeticRule{
84+ Domain: domain,
85+ Selector: selector,
86+ })
87+ }
88+ }
89+
90+ // Convert rules to JSON
91+ rulesJSON, err := json.Marshal(rules)
92+ if err != nil {
93+ logging.Error(fmt.Sprintf("Failed to marshal cosmetic rules: %v", err))
94+ return cosmeticScript
95+ }
96+
97+ // Build the complete script with domain-specific rules
98+ initScript := fmt.Sprintf(`
99+// Initialize cosmetic filtering for domain: %s
100+(function() {
101+ if (typeof window.__dumber_cosmetic_init === 'function') {
102+ window.__dumber_cosmetic_init(%s);
103+ console.log('[dumber] Cosmetic filtering initialized with %d rules');
104+ } else {
105+ console.warn('[dumber] Cosmetic filtering not available');
106+ }
107+})();`, domain, string(rulesJSON), len(rules))
108+
109+ return cosmeticScript + "\n" + initScript
110+}
111+
112+// UpdateRulesForDomain adds new rules for a specific domain
113+func (ci *CosmeticInjector) UpdateRulesForDomain(domain string, newSelectors []string) {
114+ if !ci.scriptEnabled {
115+ return
116+ }
117+
118+ if len(newSelectors) == 0 {
119+ return
120+ }
121+
122+ if domain == "" {
123+ // Add to generic rules
124+ ci.genericRules = append(ci.genericRules, newSelectors...)
125+ } else {
126+ // Add to domain-specific rules
127+ if _, exists := ci.domainRules[domain]; !exists {
128+ ci.domainRules[domain] = make([]string, 0)
129+ }
130+ ci.domainRules[domain] = append(ci.domainRules[domain], newSelectors...)
131+ }
132+
133+ logging.Debug(fmt.Sprintf("Added %d new cosmetic rules for domain: %s", len(newSelectors), domain))
134+}
135+
136+// GetUpdateScript returns JavaScript to update rules dynamically
137+func (ci *CosmeticInjector) GetUpdateScript(newSelectors []string) string {
138+ if !ci.scriptEnabled || len(newSelectors) == 0 {
139+ return ""
140+ }
141+
142+ selectorsJSON, err := json.Marshal(newSelectors)
143+ if err != nil {
144+ logging.Error(fmt.Sprintf("Failed to marshal update selectors: %v", err))
145+ return ""
146+ }
147+
148+ return fmt.Sprintf(`
149+(function() {
150+ if (typeof window.__dumber_cosmetic_update === 'function') {
151+ window.__dumber_cosmetic_update(%s);
152+ console.log('[dumber] Updated cosmetic rules with %d new selectors');
153+ }
154+})();`, string(selectorsJSON), len(newSelectors))
155+}
156+
157+// GetCleanupScript returns JavaScript to cleanup cosmetic filtering on navigation
158+func (ci *CosmeticInjector) GetCleanupScript() string {
159+ return `
160+(function() {
161+ if (typeof window.__dumber_cosmetic_cleanup === 'function') {
162+ window.__dumber_cosmetic_cleanup();
163+ console.log('[dumber] Cosmetic filtering cleaned up');
164+ }
165+})();`
166+}
167+
168+// GetRulesForDomain returns all cosmetic rules that apply to a domain
169+func (ci *CosmeticInjector) GetRulesForDomain(domain string) []string {
170+ var rules []string
171+
172+ // Add generic rules
173+ rules = append(rules, ci.genericRules...)
174+
175+ // Add domain-specific rules
176+ if domainSelectors, exists := ci.domainRules[domain]; exists {
177+ rules = append(rules, domainSelectors...)
178+ }
179+
180+ // Also check for subdomain matches
181+ domainParts := strings.Split(domain, ".")
182+ if len(domainParts) > 2 {
183+ // Try parent domain (e.g., "example.com" for "www.example.com")
184+ parentDomain := strings.Join(domainParts[1:], ".")
185+ if parentSelectors, exists := ci.domainRules[parentDomain]; exists {
186+ rules = append(rules, parentSelectors...)
187+ }
188+ }
189+
190+ return rules
191+}
192+
193+// Enable enables or disables cosmetic filtering
194+func (ci *CosmeticInjector) Enable(enabled bool) {
195+ ci.scriptEnabled = enabled
196+ if enabled {
197+ logging.Info("Cosmetic filtering enabled")
198+ } else {
199+ logging.Info("Cosmetic filtering disabled")
200+ }
201+}
202+
203+// IsEnabled returns whether cosmetic filtering is enabled
204+func (ci *CosmeticInjector) IsEnabled() bool {
205+ return ci.scriptEnabled
206+}
207+
208+// GetStats returns statistics about loaded cosmetic rules
209+func (ci *CosmeticInjector) GetStats() map[string]interface{} {
210+ domainCount := len(ci.domainRules)
211+ totalRules := len(ci.genericRules)
212+
213+ for _, selectors := range ci.domainRules {
214+ totalRules += len(selectors)
215+ }
216+
217+ return map[string]interface{}{
218+ "enabled": ci.scriptEnabled,
219+ "generic_rules": len(ci.genericRules),
220+ "domain_count": domainCount,
221+ "total_rules": totalRules,
222+ }
223+}
A internal/filtering/cosmetic/injector.js +237 -0
@@ -0,0 +1,237 @@
1+const CosmeticFilter = (() => {
2+ const hiddenElements = new WeakSet();
3+ const selectors = {
4+ generic: [], // Apply everywhere
5+ specific: [], // Domain-specific
6+ procedural: [] // Complex selectors with :has(), etc.
7+ };
8+
9+ // Safe anti-breakage scriptlets that avoid WebKit internal interference
10+ const antiBreakage = {
11+ // Safely stub ad services without modifying global setTimeout
12+ neutralizeAdPromises() {
13+ try {
14+ // Use WeakMap to avoid direct property modification
15+ const stubServices = new WeakMap();
16+
17+ // Only stub if not already defined to avoid conflicts
18+ if (typeof window.googletag === 'undefined') {
19+ window.googletag = {
20+ cmd: [],
21+ push: () => {},
22+ enableServices: () => {},
23+ display: () => {},
24+ defineSlot: () => ({ addService: () => {}, setTargeting: () => {} })
25+ };
26+ }
27+
28+ // Stub Google Analytics safely
29+ if (typeof window.gtag === 'undefined') {
30+ window.gtag = function() {};
31+ }
32+ if (typeof window.ga === 'undefined') {
33+ window.ga = function() {};
34+ }
35+ } catch (e) {
36+ // Fail silently to avoid breaking pages
37+ console.debug('[dumber] Anti-breakage initialization failed:', e);
38+ }
39+ },
40+
41+ // Clean up loading indicators after page load
42+ cleanupLoadingIndicators() {
43+ // Wait for page to be more stable before cleanup
44+ const cleanup = () => {
45+ try {
46+ // Only hide elements that are clearly loading indicators
47+ const loadingSelectors = [
48+ '.loading:not([role])', // Avoid hiding important loading states
49+ '.spinner:not([aria-live])',
50+ '.loader:not([aria-live])',
51+ '[class*="ad-loading"]:not([aria-live])'
52+ ];
53+
54+ loadingSelectors.forEach(selector => {
55+ document.querySelectorAll(selector).forEach(el => {
56+ // Check if element is actually a loading indicator
57+ const rect = el.getBoundingClientRect();
58+ if (rect.width > 0 && rect.height > 0) {
59+ el.style.opacity = '0.3';
60+ el.style.transition = 'opacity 0.5s';
61+ }
62+ });
63+ });
64+ } catch (e) {
65+ console.debug('[dumber] Loading cleanup failed:', e);
66+ }
67+ };
68+
69+ // Defer cleanup to avoid interfering with page initialization
70+ if (document.readyState === 'complete') {
71+ setTimeout(cleanup, 2000);
72+ } else {
73+ window.addEventListener('load', () => setTimeout(cleanup, 2000));
74+ }
75+ }
76+ };
77+
78+ // High-performance element hiding
79+ const hideElements = (elements) => {
80+ const styleId = 'dumber-cosmetic-style';
81+ let styleEl = document.getElementById(styleId);
82+
83+ if (!styleEl) {
84+ styleEl = document.createElement('style');
85+ styleEl.id = styleId;
86+ styleEl.textContent = '';
87+ (document.head || document.documentElement).appendChild(styleEl);
88+ }
89+
90+ // Batch CSS injection for performance
91+ const cssRules = elements.map(selector =>
92+ `${selector} { display: none !important; }`
93+ ).join('\n');
94+
95+ styleEl.textContent += cssRules;
96+ };
97+
98+ // Handle complex procedural selectors
99+ const applyProceduralFilters = () => {
100+ selectors.procedural.forEach(rule => {
101+ if (rule.includes(':has(')) {
102+ // Handle :has() pseudo-selector
103+ const match = rule.match(/(.+):has\((.+)\)/);
104+ if (match) {
105+ const [_, parent, child] = match;
106+ document.querySelectorAll(parent).forEach(el => {
107+ if (el.querySelector(child)) {
108+ el.style.display = 'none';
109+ hiddenElements.add(el);
110+ }
111+ });
112+ }
113+ } else if (rule.includes(':not(')) {
114+ // Handle :not() pseudo-selector
115+ try {
116+ document.querySelectorAll(rule).forEach(el => {
117+ el.style.display = 'none';
118+ hiddenElements.add(el);
119+ });
120+ } catch (e) {
121+ // Invalid selector, skip
122+ }
123+ }
124+ });
125+ };
126+
127+ // MutationObserver for lazy-loaded content
128+ const observer = new MutationObserver((mutations) => {
129+ // Debounce for performance
130+ if (observer.timeout) {
131+ clearTimeout(observer.timeout);
132+ }
133+
134+ observer.timeout = setTimeout(() => {
135+ // Check if new elements match our selectors
136+ const allSelectors = [...selectors.generic, ...selectors.specific];
137+
138+ mutations.forEach(mutation => {
139+ // Handle added nodes
140+ mutation.addedNodes.forEach(node => {
141+ if (node.nodeType !== 1) return; // Only element nodes
142+
143+ // Check the node itself
144+ allSelectors.forEach(selector => {
145+ if (node.matches && node.matches(selector)) {
146+ node.style.display = 'none';
147+ hiddenElements.add(node);
148+ }
149+ });
150+
151+ // Check descendants
152+ if (node.querySelectorAll) {
153+ allSelectors.forEach(selector => {
154+ node.querySelectorAll(selector).forEach(el => {
155+ el.style.display = 'none';
156+ hiddenElements.add(el);
157+ });
158+ });
159+ }
160+ });
161+
162+ // Handle attribute changes (for dynamic ads)
163+ if (mutation.type === 'attributes') {
164+ const target = mutation.target;
165+ allSelectors.forEach(selector => {
166+ if (target.matches && target.matches(selector)) {
167+ target.style.display = 'none';
168+ hiddenElements.add(target);
169+ }
170+ });
171+ }
172+ });
173+
174+ // Reapply procedural filters
175+ applyProceduralFilters();
176+ }, 50); // 50ms debounce
177+ });
178+
179+ // Initialize cosmetic filtering
180+ const init = (rules) => {
181+ // Initialize safe anti-breakage scriptlets
182+ antiBreakage.neutralizeAdPromises();
183+ antiBreakage.cleanupLoadingIndicators();
184+
185+ // Parse and categorize rules
186+ rules.forEach(rule => {
187+ if (rule.includes(':has(') || rule.includes(':not(')) {
188+ selectors.procedural.push(rule);
189+ } else if (rule.domain === window.location.hostname) {
190+ selectors.specific.push(rule.selector);
191+ } else {
192+ selectors.generic.push(rule.selector);
193+ }
194+ });
195+
196+ // Apply initial hiding
197+ hideElements([...selectors.generic, ...selectors.specific]);
198+ applyProceduralFilters();
199+
200+ // Start observing for changes
201+ observer.observe(document.documentElement, {
202+ childList: true,
203+ subtree: true,
204+ attributes: true,
205+ attributeFilter: ['class', 'id', 'style']
206+ });
207+ };
208+
209+ // Performance optimization: batch rule updates
210+ const updateRules = (newRules) => {
211+ // Diff and apply only new rules
212+ const newSelectors = newRules.filter(r =>
213+ !selectors.generic.includes(r) &&
214+ !selectors.specific.includes(r)
215+ );
216+
217+ if (newSelectors.length > 0) {
218+ hideElements(newSelectors);
219+ selectors.generic.push(...newSelectors);
220+ }
221+ };
222+
223+ // Cleanup function for navigation
224+ const cleanup = () => {
225+ observer.disconnect();
226+ hiddenElements.clear();
227+ const styleEl = document.getElementById('dumber-cosmetic-style');
228+ if (styleEl) styleEl.remove();
229+ };
230+
231+ return { init, updateRules, cleanup };
232+})();
233+
234+// Inject rules from native
235+window.__dumber_cosmetic_init = (rules) => CosmeticFilter.init(rules);
236+window.__dumber_cosmetic_update = (rules) => CosmeticFilter.updateRules(rules);
237+window.__dumber_cosmetic_cleanup = () => CosmeticFilter.cleanup();⏎̸
@@ -0,0 +1,237 @@
1+const CosmeticFilter = (() => {
2+ const hiddenElements = new WeakSet();
3+ const selectors = {
4+ generic: [], // Apply everywhere
5+ specific: [], // Domain-specific
6+ procedural: [] // Complex selectors with :has(), etc.
7+ };
8+
9+ // Safe anti-breakage scriptlets that avoid WebKit internal interference
10+ const antiBreakage = {
11+ // Safely stub ad services without modifying global setTimeout
12+ neutralizeAdPromises() {
13+ try {
14+ // Use WeakMap to avoid direct property modification
15+ const stubServices = new WeakMap();
16+
17+ // Only stub if not already defined to avoid conflicts
18+ if (typeof window.googletag === 'undefined') {
19+ window.googletag = {
20+ cmd: [],
21+ push: () => {},
22+ enableServices: () => {},
23+ display: () => {},
24+ defineSlot: () => ({ addService: () => {}, setTargeting: () => {} })
25+ };
26+ }
27+
28+ // Stub Google Analytics safely
29+ if (typeof window.gtag === 'undefined') {
30+ window.gtag = function() {};
31+ }
32+ if (typeof window.ga === 'undefined') {
33+ window.ga = function() {};
34+ }
35+ } catch (e) {
36+ // Fail silently to avoid breaking pages
37+ console.debug('[dumber] Anti-breakage initialization failed:', e);
38+ }
39+ },
40+
41+ // Clean up loading indicators after page load
42+ cleanupLoadingIndicators() {
43+ // Wait for page to be more stable before cleanup
44+ const cleanup = () => {
45+ try {
46+ // Only hide elements that are clearly loading indicators
47+ const loadingSelectors = [
48+ '.loading:not([role])', // Avoid hiding important loading states
49+ '.spinner:not([aria-live])',
50+ '.loader:not([aria-live])',
51+ '[class*="ad-loading"]:not([aria-live])'
52+ ];
53+
54+ loadingSelectors.forEach(selector => {
55+ document.querySelectorAll(selector).forEach(el => {
56+ // Check if element is actually a loading indicator
57+ const rect = el.getBoundingClientRect();
58+ if (rect.width > 0 && rect.height > 0) {
59+ el.style.opacity = '0.3';
60+ el.style.transition = 'opacity 0.5s';
61+ }
62+ });
63+ });
64+ } catch (e) {
65+ console.debug('[dumber] Loading cleanup failed:', e);
66+ }
67+ };
68+
69+ // Defer cleanup to avoid interfering with page initialization
70+ if (document.readyState === 'complete') {
71+ setTimeout(cleanup, 2000);
72+ } else {
73+ window.addEventListener('load', () => setTimeout(cleanup, 2000));
74+ }
75+ }
76+ };
77+
78+ // High-performance element hiding
79+ const hideElements = (elements) => {
80+ const styleId = 'dumber-cosmetic-style';
81+ let styleEl = document.getElementById(styleId);
82+
83+ if (!styleEl) {
84+ styleEl = document.createElement('style');
85+ styleEl.id = styleId;
86+ styleEl.textContent = '';
87+ (document.head || document.documentElement).appendChild(styleEl);
88+ }
89+
90+ // Batch CSS injection for performance
91+ const cssRules = elements.map(selector =>
92+ `${selector} { display: none !important; }`
93+ ).join('\n');
94+
95+ styleEl.textContent += cssRules;
96+ };
97+
98+ // Handle complex procedural selectors
99+ const applyProceduralFilters = () => {
100+ selectors.procedural.forEach(rule => {
101+ if (rule.includes(':has(')) {
102+ // Handle :has() pseudo-selector
103+ const match = rule.match(/(.+):has\((.+)\)/);
104+ if (match) {
105+ const [_, parent, child] = match;
106+ document.querySelectorAll(parent).forEach(el => {
107+ if (el.querySelector(child)) {
108+ el.style.display = 'none';
109+ hiddenElements.add(el);
110+ }
111+ });
112+ }
113+ } else if (rule.includes(':not(')) {
114+ // Handle :not() pseudo-selector
115+ try {
116+ document.querySelectorAll(rule).forEach(el => {
117+ el.style.display = 'none';
118+ hiddenElements.add(el);
119+ });
120+ } catch (e) {
121+ // Invalid selector, skip
122+ }
123+ }
124+ });
125+ };
126+
127+ // MutationObserver for lazy-loaded content
128+ const observer = new MutationObserver((mutations) => {
129+ // Debounce for performance
130+ if (observer.timeout) {
131+ clearTimeout(observer.timeout);
132+ }
133+
134+ observer.timeout = setTimeout(() => {
135+ // Check if new elements match our selectors
136+ const allSelectors = [...selectors.generic, ...selectors.specific];
137+
138+ mutations.forEach(mutation => {
139+ // Handle added nodes
140+ mutation.addedNodes.forEach(node => {
141+ if (node.nodeType !== 1) return; // Only element nodes
142+
143+ // Check the node itself
144+ allSelectors.forEach(selector => {
145+ if (node.matches && node.matches(selector)) {
146+ node.style.display = 'none';
147+ hiddenElements.add(node);
148+ }
149+ });
150+
151+ // Check descendants
152+ if (node.querySelectorAll) {
153+ allSelectors.forEach(selector => {
154+ node.querySelectorAll(selector).forEach(el => {
155+ el.style.display = 'none';
156+ hiddenElements.add(el);
157+ });
158+ });
159+ }
160+ });
161+
162+ // Handle attribute changes (for dynamic ads)
163+ if (mutation.type === 'attributes') {
164+ const target = mutation.target;
165+ allSelectors.forEach(selector => {
166+ if (target.matches && target.matches(selector)) {
167+ target.style.display = 'none';
168+ hiddenElements.add(target);
169+ }
170+ });
171+ }
172+ });
173+
174+ // Reapply procedural filters
175+ applyProceduralFilters();
176+ }, 50); // 50ms debounce
177+ });
178+
179+ // Initialize cosmetic filtering
180+ const init = (rules) => {
181+ // Initialize safe anti-breakage scriptlets
182+ antiBreakage.neutralizeAdPromises();
183+ antiBreakage.cleanupLoadingIndicators();
184+
185+ // Parse and categorize rules
186+ rules.forEach(rule => {
187+ if (rule.includes(':has(') || rule.includes(':not(')) {
188+ selectors.procedural.push(rule);
189+ } else if (rule.domain === window.location.hostname) {
190+ selectors.specific.push(rule.selector);
191+ } else {
192+ selectors.generic.push(rule.selector);
193+ }
194+ });
195+
196+ // Apply initial hiding
197+ hideElements([...selectors.generic, ...selectors.specific]);
198+ applyProceduralFilters();
199+
200+ // Start observing for changes
201+ observer.observe(document.documentElement, {
202+ childList: true,
203+ subtree: true,
204+ attributes: true,
205+ attributeFilter: ['class', 'id', 'style']
206+ });
207+ };
208+
209+ // Performance optimization: batch rule updates
210+ const updateRules = (newRules) => {
211+ // Diff and apply only new rules
212+ const newSelectors = newRules.filter(r =>
213+ !selectors.generic.includes(r) &&
214+ !selectors.specific.includes(r)
215+ );
216+
217+ if (newSelectors.length > 0) {
218+ hideElements(newSelectors);
219+ selectors.generic.push(...newSelectors);
220+ }
221+ };
222+
223+ // Cleanup function for navigation
224+ const cleanup = () => {
225+ observer.disconnect();
226+ hiddenElements.clear();
227+ const styleEl = document.getElementById('dumber-cosmetic-style');
228+ if (styleEl) styleEl.remove();
229+ };
230+
231+ return { init, updateRules, cleanup };
232+})();
233+
234+// Inject rules from native
235+window.__dumber_cosmetic_init = (rules) => CosmeticFilter.init(rules);
236+window.__dumber_cosmetic_update = (rules) => CosmeticFilter.updateRules(rules);
237+window.__dumber_cosmetic_cleanup = () => CosmeticFilter.cleanup();⏎̸
A internal/filtering/errors.go +17 -0
@@ -0,0 +1,17 @@
1+package filtering
2+
3+import "errors"
4+
5+var (
6+ // ErrFiltersNotReady indicates that filters are not yet compiled/loaded
7+ ErrFiltersNotReady = errors.New("filters not ready")
8+
9+ // ErrInvalidFilterFormat indicates malformed filter data
10+ ErrInvalidFilterFormat = errors.New("invalid filter format")
11+
12+ // ErrCacheCorrupted indicates corrupted cache data
13+ ErrCacheCorrupted = errors.New("cache corrupted")
14+
15+ // ErrNetworkError indicates network-related errors during filter download
16+ ErrNetworkError = errors.New("network error")
17+)
@@ -0,0 +1,17 @@
1+package filtering
2+
3+import "errors"
4+
5+var (
6+ // ErrFiltersNotReady indicates that filters are not yet compiled/loaded
7+ ErrFiltersNotReady = errors.New("filters not ready")
8+
9+ // ErrInvalidFilterFormat indicates malformed filter data
10+ ErrInvalidFilterFormat = errors.New("invalid filter format")
11+
12+ // ErrCacheCorrupted indicates corrupted cache data
13+ ErrCacheCorrupted = errors.New("cache corrupted")
14+
15+ // ErrNetworkError indicates network-related errors during filter download
16+ ErrNetworkError = errors.New("network error")
17+)
A internal/filtering/integration.go +32 -0
@@ -0,0 +1,32 @@
1+package filtering
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// SetupFilterSystem creates and initializes the complete filtering system
11+func SetupFilterSystem() (*FilterManager, error) {
12+ // Create filter store
13+ store, err := NewFileFilterStore()
14+ if err != nil {
15+ return nil, fmt.Errorf("failed to create filter store: %w", err)
16+ }
17+
18+ // Create filter compiler
19+ compiler := NewDefaultFilterCompiler()
20+
21+ // Create filter manager with all components
22+ manager := NewFilterManager(store, compiler)
23+
24+ logging.Info("Filter system initialized successfully")
25+ return manager, nil
26+}
27+
28+// InitializeFiltersAsync initializes the filtering system asynchronously
29+func InitializeFiltersAsync(manager *FilterManager) error {
30+ ctx := context.Background()
31+ return manager.InitAsync(ctx)
32+}
@@ -0,0 +1,32 @@
1+package filtering
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// SetupFilterSystem creates and initializes the complete filtering system
11+func SetupFilterSystem() (*FilterManager, error) {
12+ // Create filter store
13+ store, err := NewFileFilterStore()
14+ if err != nil {
15+ return nil, fmt.Errorf("failed to create filter store: %w", err)
16+ }
17+
18+ // Create filter compiler
19+ compiler := NewDefaultFilterCompiler()
20+
21+ // Create filter manager with all components
22+ manager := NewFilterManager(store, compiler)
23+
24+ logging.Info("Filter system initialized successfully")
25+ return manager, nil
26+}
27+
28+// InitializeFiltersAsync initializes the filtering system asynchronously
29+func InitializeFiltersAsync(manager *FilterManager) error {
30+ ctx := context.Background()
31+ return manager.InitAsync(ctx)
32+}
A internal/filtering/manager.go +526 -0
@@ -0,0 +1,526 @@
1+// Package filtering provides filter management and compilation for content blocking.
2+package filtering
3+
4+import (
5+ "bufio"
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "net/http"
10+ "sync"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/filtering/converter"
14+ "github.com/bnema/dumber/internal/filtering/cosmetic"
15+ "github.com/bnema/dumber/internal/logging"
16+)
17+
18+const (
19+ defaultUpdateHours = 24 // Default update interval in hours
20+ httpTimeoutSeconds = 30 // HTTP client timeout in seconds
21+ contextCheckInterval = 1000 // Check context cancellation every N lines
22+)
23+
24+// FilterReadyCallback is called when filters are loaded/updated
25+type FilterReadyCallback func()
26+
27+// FilterManager manages filter loading, compilation, and updates
28+type FilterManager struct {
29+ store FilterStore
30+ compiler FilterCompiler
31+ cosmeticInjector *cosmetic.CosmeticInjector
32+ updateInterval time.Duration
33+ compileMutex sync.RWMutex
34+ compiled *CompiledFilters
35+ onFiltersReady FilterReadyCallback
36+}
37+
38+// FilterStore defines the interface for filter storage operations
39+type FilterStore interface {
40+ LoadCached() (*CompiledFilters, error)
41+ SaveCache(filters *CompiledFilters) error
42+ GetCacheInfo() (bool, time.Time, error)
43+}
44+
45+// FilterCompiler defines the interface for filter compilation
46+type FilterCompiler interface {
47+ CompileFromSources(ctx context.Context, sources []string) (*CompiledFilters, error)
48+ CompileFromData(data []byte) (*CompiledFilters, error)
49+}
50+
51+// CosmeticInjector defines the interface for cosmetic filter injection
52+type CosmeticInjector interface {
53+ InjectRules(rules map[string][]string) error
54+ GetScriptForDomain(domain string) string
55+}
56+
57+// CompiledFilters represents the compiled filter data
58+type CompiledFilters struct {
59+ NetworkRules []converter.WebKitRule `json:"network_rules"`
60+ CosmeticRules map[string][]string `json:"cosmetic_rules"`
61+ GenericHiding []string `json:"generic_hiding"`
62+ CompiledAt time.Time `json:"compiled_at"`
63+ Version string `json:"version"`
64+ mu sync.RWMutex
65+}
66+
67+// NewFilterManager creates a new filter manager
68+func NewFilterManager(store FilterStore, compiler FilterCompiler) *FilterManager {
69+ return &FilterManager{
70+ store: store,
71+ compiler: compiler,
72+ cosmeticInjector: cosmetic.NewCosmeticInjector(),
73+ updateInterval: defaultUpdateHours * time.Hour, // Default update interval
74+ compiled: &CompiledFilters{},
75+ onFiltersReady: nil,
76+ }
77+}
78+
79+// SetFiltersReadyCallback sets a callback to be called when filters are loaded/updated
80+func (fm *FilterManager) SetFiltersReadyCallback(callback FilterReadyCallback) {
81+ fm.onFiltersReady = callback
82+}
83+
84+// InitAsync initializes filter loading in background
85+func (fm *FilterManager) InitAsync(ctx context.Context) error {
86+ // Load pre-compiled filters in background
87+ go fm.loadCompiledFilters(ctx)
88+
89+ // Start periodic update checker
90+ go fm.startUpdateLoop(ctx)
91+
92+ // Note: compileIfNeeded removed to prevent race condition
93+ // Cache age checking is now handled in loadCompiledFilters
94+
95+ return nil
96+}
97+
98+func (fm *FilterManager) loadCompiledFilters(ctx context.Context) {
99+ // Try to load from cache first
100+ if cached, err := fm.store.LoadCached(); err == nil {
101+ logging.Info(fmt.Sprintf("[filtering] Successfully loaded cache with %d network rules, %d cosmetic domains",
102+ len(cached.NetworkRules), len(cached.CosmeticRules)))
103+ fm.applyFilters(cached)
104+
105+ // Check if cache needs update
106+ if exists, lastModified, _ := fm.store.GetCacheInfo(); exists {
107+ if time.Since(lastModified) > fm.updateInterval {
108+ // Schedule background update without clearing current filters
109+ logging.Info("[filtering] Cache is outdated, scheduling background update")
110+ go fm.updateFiltersInBackground(ctx)
111+ }
112+ }
113+ return
114+ } else {
115+ logging.Error(fmt.Sprintf("[filtering] Failed to load cache: %v", err))
116+ }
117+
118+ // Only compile if no cache exists
119+ logging.Info("[filtering] No cache found, compiling from sources")
120+ fm.compileFromSources(ctx)
121+}
122+
123+func (fm *FilterManager) compileIfNeeded(ctx context.Context) {
124+ // Check if cache exists and is recent
125+ exists, lastModified, err := fm.store.GetCacheInfo()
126+ if err != nil {
127+ fm.compileFromSources(ctx)
128+ return
129+ }
130+
131+ // If cache is older than update interval, recompile
132+ if !exists || time.Since(lastModified) > fm.updateInterval {
133+ fm.compileFromSources(ctx)
134+ }
135+}
136+
137+func (fm *FilterManager) compileFromSources(ctx context.Context) {
138+ sources := []string{
139+ "https://easylist.to/easylist/easylist.txt",
140+ "https://easylist.to/easylist/easyprivacy.txt",
141+ "https://easylist-downloads.adblockplus.org/liste_fr.txt", // For French sites like lesnumeriques.com
142+ }
143+
144+ var wg sync.WaitGroup
145+ results := make(chan *CompiledFilters, len(sources))
146+
147+ for _, url := range sources {
148+ wg.Add(1)
149+ go func(u string) {
150+ defer wg.Done()
151+ if filters, err := fm.downloadAndCompile(ctx, u); err == nil {
152+ results <- filters
153+ } else {
154+ logging.Error(fmt.Sprintf("[filtering] Failed to download/compile %s: %v", u, err))
155+ }
156+ }(url)
157+ }
158+
159+ // Wait for all compilations
160+ go func() {
161+ wg.Wait()
162+ close(results)
163+ }()
164+
165+ // Merge results as they come in
166+ merged := NewCompiledFilters()
167+ hasContent := false
168+
169+ for compiled := range results {
170+ if compiled != nil && len(compiled.NetworkRules) > 0 {
171+ hasContent = true
172+ merged.Merge(compiled)
173+ // Apply incrementally for faster initial blocking
174+ logging.Info(fmt.Sprintf("[filtering] Applying incremental filters: %d network rules", len(merged.NetworkRules)))
175+ fm.applyFilters(merged)
176+ }
177+ }
178+
179+ // Only save/apply final result if we got actual content
180+ if hasContent {
181+ logging.Info(fmt.Sprintf("[filtering] Successfully compiled %d network rules from sources", len(merged.NetworkRules)))
182+
183+ // Cache the final result
184+ if err := fm.store.SaveCache(merged); err != nil {
185+ logging.Error(fmt.Sprintf("Failed to save filter cache: %v", err))
186+ }
187+ } else {
188+ logging.Warn("[filtering] No network rules were successfully compiled from any source - keeping existing filters if any")
189+ }
190+}
191+
192+func (fm *FilterManager) downloadAndCompile(ctx context.Context, url string) (*CompiledFilters, error) {
193+ // Create HTTP client with timeout
194+ client := &http.Client{
195+ Timeout: httpTimeoutSeconds * time.Second,
196+ }
197+
198+ // Create request with context
199+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
200+ if err != nil {
201+ return nil, fmt.Errorf("failed to create request for %s: %w", url, err)
202+ }
203+
204+ // Set User-Agent to identify as browser
205+ req.Header.Set("User-Agent", "Dumber Browser/1.0")
206+
207+ // Download filter list
208+ resp, err := client.Do(req)
209+ if err != nil {
210+ return nil, fmt.Errorf("failed to download %s: %w", url, ErrNetworkError)
211+ }
212+ defer func() {
213+ if closeErr := resp.Body.Close(); closeErr != nil {
214+ logging.Error(fmt.Sprintf("Failed to close response body for %s: %v", url, closeErr))
215+ }
216+ }()
217+
218+ if resp.StatusCode != http.StatusOK {
219+ return nil, fmt.Errorf("HTTP %d downloading %s", resp.StatusCode, url)
220+ }
221+
222+ // Create filter converter
223+ filterConverter := converter.NewFilterConverter()
224+ compiled := NewCompiledFilters()
225+
226+ // Parse line by line for memory efficiency
227+ scanner := bufio.NewScanner(resp.Body)
228+ lineCount := 0
229+
230+ for scanner.Scan() {
231+ line := scanner.Text()
232+ lineCount++
233+
234+ // Check context cancellation periodically
235+ if lineCount%contextCheckInterval == 0 {
236+ select {
237+ case <-ctx.Done():
238+ return nil, ctx.Err()
239+ default:
240+ }
241+ }
242+
243+ // Convert each line
244+ if err := filterConverter.ConvertEasyListLine(line); err != nil {
245+ logging.Debug(fmt.Sprintf("Failed to convert filter line: %s, error: %v", line, err))
246+ continue
247+ }
248+ }
249+
250+ if err := scanner.Err(); err != nil {
251+ return nil, fmt.Errorf("error reading filter list %s: %w", url, err)
252+ }
253+
254+ // Get compiled rules from converter
255+ compiled.NetworkRules = filterConverter.GetNetworkRules()
256+ compiled.CosmeticRules = filterConverter.GetCosmeticRules()
257+ compiled.GenericHiding = filterConverter.GetGenericHiding()
258+ compiled.CompiledAt = time.Now()
259+ compiled.Version = fmt.Sprintf("downloaded-%d", time.Now().Unix())
260+
261+ // Log parsing results
262+ logging.Info(fmt.Sprintf("[filtering] Parsed %s: %d lines, %d network rules, %d cosmetic domains, %d generic hiding rules",
263+ url, lineCount, len(compiled.NetworkRules), len(compiled.CosmeticRules), len(compiled.GenericHiding)))
264+
265+ return compiled, nil
266+}
267+
268+func (fm *FilterManager) applyFilters(filters *CompiledFilters) {
269+ fm.compileMutex.Lock()
270+ defer fm.compileMutex.Unlock()
271+
272+ // Add uBlock Origin style scriptlets to handle broken page loading
273+
274+ // Instead of blocking completely, add exception rules for critical ad scripts that break page loading
275+ // This allows the page to load while neutralizing tracking
276+ antiBreakageRules := []converter.WebKitRule{
277+ {
278+ Trigger: converter.Trigger{
279+ URLFilter: "googlesyndication.com",
280+ ResourceType: []string{converter.ResourceTypeScript},
281+ },
282+ Action: converter.Action{
283+ Type: converter.ActionTypeBlock,
284+ },
285+ },
286+ {
287+ Trigger: converter.Trigger{
288+ URLFilter: "doubleclick.net",
289+ },
290+ Action: converter.Action{
291+ Type: converter.ActionTypeBlock,
292+ },
293+ },
294+ {
295+ Trigger: converter.Trigger{
296+ URLFilter: "googletagservices.com/tag/js/gpt.js",
297+ ResourceType: []string{converter.ResourceTypeScript},
298+ },
299+ Action: converter.Action{
300+ Type: converter.ActionTypeBlock,
301+ },
302+ },
303+ }
304+ filters.NetworkRules = append(filters.NetworkRules, antiBreakageRules...)
305+
306+ // Add generic cosmetic rules for common ad loading indicators and infinite loaders
307+ antiLoaderSelectors := []string{
308+ // Generic ad loader patterns (work across all sites)
309+ ".ad-loading", ".ad-placeholder", ".loading-ad", ".ad-spinner",
310+ "[class*='loading'][class*='ad']", "[id*='loading'][id*='ad']",
311+ ".spinner[class*='ad']", "[data-loading*='ad']",
312+ // Generic infinite loader patterns
313+ ".infinite-loader", ".loading-infinite", "[data-loading='infinite']",
314+ ".loader[style*='infinite']", ".spinner[data-infinite='true']",
315+ // Generic advertising loader patterns (multi-language)
316+ ".advertisement-loading", ".ads-loading", ".publicity-loading",
317+ }
318+
319+ // Add generic hiding rules for loader prevention
320+ filters.GenericHiding = append(filters.GenericHiding, antiLoaderSelectors...)
321+
322+ logging.Info(fmt.Sprintf("[filtering] Applying filters: %d network rules (including test rule), %d cosmetic domains, %d generic hiding rules",
323+ len(filters.NetworkRules), len(filters.CosmeticRules), len(filters.GenericHiding)))
324+
325+ fm.compiled = filters
326+
327+ // Inject cosmetic rules
328+ if err := fm.cosmeticInjector.InjectRules(filters.CosmeticRules); err != nil {
329+ logging.Error(fmt.Sprintf("Failed to inject cosmetic rules: %v", err))
330+ }
331+
332+ // Notify that filters are ready
333+ if fm.onFiltersReady != nil {
334+ go fm.onFiltersReady()
335+ }
336+}
337+
338+func (fm *FilterManager) startUpdateLoop(ctx context.Context) {
339+ ticker := time.NewTicker(fm.updateInterval)
340+ defer ticker.Stop()
341+
342+ for {
343+ select {
344+ case <-ctx.Done():
345+ return
346+ case <-ticker.C:
347+ // Check if cache needs update and schedule background update
348+ if exists, lastModified, _ := fm.store.GetCacheInfo(); exists {
349+ if time.Since(lastModified) > fm.updateInterval {
350+ logging.Info("[filtering] Periodic update triggered")
351+ go fm.updateFiltersInBackground(ctx)
352+ }
353+ }
354+ }
355+ }
356+}
357+
358+// updateFiltersInBackground updates filters without clearing existing ones
359+func (fm *FilterManager) updateFiltersInBackground(ctx context.Context) {
360+ logging.Info("[filtering] Starting background filter update")
361+
362+ // Compile new filters
363+ sources := []string{
364+ "https://easylist.to/easylist/easylist.txt",
365+ "https://easylist.to/easylist/easyprivacy.txt",
366+ "https://easylist-downloads.adblockplus.org/liste_fr.txt", // For French sites like lesnumeriques.com
367+ }
368+
369+ var wg sync.WaitGroup
370+ results := make(chan *CompiledFilters, len(sources))
371+
372+ for _, url := range sources {
373+ wg.Add(1)
374+ go func(u string) {
375+ defer wg.Done()
376+ if filters, err := fm.downloadAndCompile(ctx, u); err == nil {
377+ results <- filters
378+ } else {
379+ logging.Warn(fmt.Sprintf("[filtering] Failed to update from %s: %v", u, err))
380+ }
381+ }(url)
382+ }
383+
384+ // Wait for all compilations
385+ go func() {
386+ wg.Wait()
387+ close(results)
388+ }()
389+
390+ // Merge results
391+ merged := NewCompiledFilters()
392+ hasContent := false
393+
394+ for compiled := range results {
395+ if compiled != nil && len(compiled.NetworkRules) > 0 {
396+ hasContent = true
397+ merged.Merge(compiled)
398+ }
399+ }
400+
401+ // Only apply and save if we got actual content
402+ if hasContent {
403+ logging.Info(fmt.Sprintf("[filtering] Background update succeeded with %d network rules", len(merged.NetworkRules)))
404+ fm.applyFilters(merged)
405+
406+ if err := fm.store.SaveCache(merged); err != nil {
407+ logging.Error(fmt.Sprintf("Failed to save updated filter cache: %v", err))
408+ }
409+ } else {
410+ logging.Warn("[filtering] Background update produced no filters, keeping existing filters")
411+ }
412+}
413+
414+// GetNetworkFilters returns the compiled network filters for WebKit
415+func (fm *FilterManager) GetNetworkFilters() ([]byte, error) {
416+ fm.compileMutex.RLock()
417+ defer fm.compileMutex.RUnlock()
418+
419+ if fm.compiled == nil {
420+ return nil, ErrFiltersNotReady
421+ }
422+
423+ json, err := fm.compiled.ToJSON()
424+ if err != nil {
425+ return nil, err
426+ }
427+
428+ return json, nil
429+}
430+
431+// GetCosmeticRulesForDomain returns cosmetic rules for a specific domain
432+func (fm *FilterManager) GetCosmeticRulesForDomain(domain string) []string {
433+ fm.compileMutex.RLock()
434+ defer fm.compileMutex.RUnlock()
435+
436+ if fm.compiled == nil {
437+ return nil
438+ }
439+
440+ return fm.compiled.GetRulesForDomain(domain)
441+}
442+
443+// GetCosmeticScript returns the cosmetic filtering script
444+func (fm *FilterManager) GetCosmeticScript() string {
445+ return fm.cosmeticInjector.GetScriptForDomain("")
446+}
447+
448+// GetCosmeticScriptForDomain returns the cosmetic filtering script for a specific domain
449+func (fm *FilterManager) GetCosmeticScriptForDomain(domain string) string {
450+ return fm.cosmeticInjector.GetScriptForDomain(domain)
451+}
452+
453+// UpdateCosmeticRules adds new cosmetic rules for a domain
454+func (fm *FilterManager) UpdateCosmeticRules(domain string, selectors []string) {
455+ fm.cosmeticInjector.UpdateRulesForDomain(domain, selectors)
456+}
457+
458+// GetCosmeticUpdateScript returns JavaScript to update cosmetic rules dynamically
459+func (fm *FilterManager) GetCosmeticUpdateScript(selectors []string) string {
460+ return fm.cosmeticInjector.GetUpdateScript(selectors)
461+}
462+
463+// GetCosmeticCleanupScript returns JavaScript to cleanup cosmetic filtering
464+func (fm *FilterManager) GetCosmeticCleanupScript() string {
465+ return fm.cosmeticInjector.GetCleanupScript()
466+}
467+
468+// NewCompiledFilters creates a new CompiledFilters instance
469+func NewCompiledFilters() *CompiledFilters {
470+ return &CompiledFilters{
471+ NetworkRules: make([]converter.WebKitRule, 0),
472+ CosmeticRules: make(map[string][]string),
473+ GenericHiding: make([]string, 0),
474+ CompiledAt: time.Now(),
475+ }
476+}
477+
478+// Merge combines two CompiledFilters instances
479+func (cf *CompiledFilters) Merge(other *CompiledFilters) {
480+ cf.mu.Lock()
481+ defer cf.mu.Unlock()
482+
483+ if other == nil {
484+ return
485+ }
486+
487+ other.mu.RLock()
488+ defer other.mu.RUnlock()
489+
490+ // Merge network rules
491+ cf.NetworkRules = append(cf.NetworkRules, other.NetworkRules...)
492+
493+ // Merge cosmetic rules
494+ for domain, rules := range other.CosmeticRules {
495+ cf.CosmeticRules[domain] = append(cf.CosmeticRules[domain], rules...)
496+ }
497+
498+ // Merge generic hiding rules
499+ cf.GenericHiding = append(cf.GenericHiding, other.GenericHiding...)
500+}
501+
502+// GetRulesForDomain returns cosmetic rules for a specific domain
503+func (cf *CompiledFilters) GetRulesForDomain(domain string) []string {
504+ cf.mu.RLock()
505+ defer cf.mu.RUnlock()
506+
507+ rules := make([]string, 0)
508+
509+ // Add generic hiding rules
510+ rules = append(rules, cf.GenericHiding...)
511+
512+ // Add domain-specific rules
513+ if domainRules, exists := cf.CosmeticRules[domain]; exists {
514+ rules = append(rules, domainRules...)
515+ }
516+
517+ return rules
518+}
519+
520+// ToJSON converts network rules to JSON format
521+func (cf *CompiledFilters) ToJSON() ([]byte, error) {
522+ cf.mu.RLock()
523+ defer cf.mu.RUnlock()
524+
525+ return json.Marshal(cf.NetworkRules)
526+}
@@ -0,0 +1,526 @@
1+// Package filtering provides filter management and compilation for content blocking.
2+package filtering
3+
4+import (
5+ "bufio"
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "net/http"
10+ "sync"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/filtering/converter"
14+ "github.com/bnema/dumber/internal/filtering/cosmetic"
15+ "github.com/bnema/dumber/internal/logging"
16+)
17+
18+const (
19+ defaultUpdateHours = 24 // Default update interval in hours
20+ httpTimeoutSeconds = 30 // HTTP client timeout in seconds
21+ contextCheckInterval = 1000 // Check context cancellation every N lines
22+)
23+
24+// FilterReadyCallback is called when filters are loaded/updated
25+type FilterReadyCallback func()
26+
27+// FilterManager manages filter loading, compilation, and updates
28+type FilterManager struct {
29+ store FilterStore
30+ compiler FilterCompiler
31+ cosmeticInjector *cosmetic.CosmeticInjector
32+ updateInterval time.Duration
33+ compileMutex sync.RWMutex
34+ compiled *CompiledFilters
35+ onFiltersReady FilterReadyCallback
36+}
37+
38+// FilterStore defines the interface for filter storage operations
39+type FilterStore interface {
40+ LoadCached() (*CompiledFilters, error)
41+ SaveCache(filters *CompiledFilters) error
42+ GetCacheInfo() (bool, time.Time, error)
43+}
44+
45+// FilterCompiler defines the interface for filter compilation
46+type FilterCompiler interface {
47+ CompileFromSources(ctx context.Context, sources []string) (*CompiledFilters, error)
48+ CompileFromData(data []byte) (*CompiledFilters, error)
49+}
50+
51+// CosmeticInjector defines the interface for cosmetic filter injection
52+type CosmeticInjector interface {
53+ InjectRules(rules map[string][]string) error
54+ GetScriptForDomain(domain string) string
55+}
56+
57+// CompiledFilters represents the compiled filter data
58+type CompiledFilters struct {
59+ NetworkRules []converter.WebKitRule `json:"network_rules"`
60+ CosmeticRules map[string][]string `json:"cosmetic_rules"`
61+ GenericHiding []string `json:"generic_hiding"`
62+ CompiledAt time.Time `json:"compiled_at"`
63+ Version string `json:"version"`
64+ mu sync.RWMutex
65+}
66+
67+// NewFilterManager creates a new filter manager
68+func NewFilterManager(store FilterStore, compiler FilterCompiler) *FilterManager {
69+ return &FilterManager{
70+ store: store,
71+ compiler: compiler,
72+ cosmeticInjector: cosmetic.NewCosmeticInjector(),
73+ updateInterval: defaultUpdateHours * time.Hour, // Default update interval
74+ compiled: &CompiledFilters{},
75+ onFiltersReady: nil,
76+ }
77+}
78+
79+// SetFiltersReadyCallback sets a callback to be called when filters are loaded/updated
80+func (fm *FilterManager) SetFiltersReadyCallback(callback FilterReadyCallback) {
81+ fm.onFiltersReady = callback
82+}
83+
84+// InitAsync initializes filter loading in background
85+func (fm *FilterManager) InitAsync(ctx context.Context) error {
86+ // Load pre-compiled filters in background
87+ go fm.loadCompiledFilters(ctx)
88+
89+ // Start periodic update checker
90+ go fm.startUpdateLoop(ctx)
91+
92+ // Note: compileIfNeeded removed to prevent race condition
93+ // Cache age checking is now handled in loadCompiledFilters
94+
95+ return nil
96+}
97+
98+func (fm *FilterManager) loadCompiledFilters(ctx context.Context) {
99+ // Try to load from cache first
100+ if cached, err := fm.store.LoadCached(); err == nil {
101+ logging.Info(fmt.Sprintf("[filtering] Successfully loaded cache with %d network rules, %d cosmetic domains",
102+ len(cached.NetworkRules), len(cached.CosmeticRules)))
103+ fm.applyFilters(cached)
104+
105+ // Check if cache needs update
106+ if exists, lastModified, _ := fm.store.GetCacheInfo(); exists {
107+ if time.Since(lastModified) > fm.updateInterval {
108+ // Schedule background update without clearing current filters
109+ logging.Info("[filtering] Cache is outdated, scheduling background update")
110+ go fm.updateFiltersInBackground(ctx)
111+ }
112+ }
113+ return
114+ } else {
115+ logging.Error(fmt.Sprintf("[filtering] Failed to load cache: %v", err))
116+ }
117+
118+ // Only compile if no cache exists
119+ logging.Info("[filtering] No cache found, compiling from sources")
120+ fm.compileFromSources(ctx)
121+}
122+
123+func (fm *FilterManager) compileIfNeeded(ctx context.Context) {
124+ // Check if cache exists and is recent
125+ exists, lastModified, err := fm.store.GetCacheInfo()
126+ if err != nil {
127+ fm.compileFromSources(ctx)
128+ return
129+ }
130+
131+ // If cache is older than update interval, recompile
132+ if !exists || time.Since(lastModified) > fm.updateInterval {
133+ fm.compileFromSources(ctx)
134+ }
135+}
136+
137+func (fm *FilterManager) compileFromSources(ctx context.Context) {
138+ sources := []string{
139+ "https://easylist.to/easylist/easylist.txt",
140+ "https://easylist.to/easylist/easyprivacy.txt",
141+ "https://easylist-downloads.adblockplus.org/liste_fr.txt", // For French sites like lesnumeriques.com
142+ }
143+
144+ var wg sync.WaitGroup
145+ results := make(chan *CompiledFilters, len(sources))
146+
147+ for _, url := range sources {
148+ wg.Add(1)
149+ go func(u string) {
150+ defer wg.Done()
151+ if filters, err := fm.downloadAndCompile(ctx, u); err == nil {
152+ results <- filters
153+ } else {
154+ logging.Error(fmt.Sprintf("[filtering] Failed to download/compile %s: %v", u, err))
155+ }
156+ }(url)
157+ }
158+
159+ // Wait for all compilations
160+ go func() {
161+ wg.Wait()
162+ close(results)
163+ }()
164+
165+ // Merge results as they come in
166+ merged := NewCompiledFilters()
167+ hasContent := false
168+
169+ for compiled := range results {
170+ if compiled != nil && len(compiled.NetworkRules) > 0 {
171+ hasContent = true
172+ merged.Merge(compiled)
173+ // Apply incrementally for faster initial blocking
174+ logging.Info(fmt.Sprintf("[filtering] Applying incremental filters: %d network rules", len(merged.NetworkRules)))
175+ fm.applyFilters(merged)
176+ }
177+ }
178+
179+ // Only save/apply final result if we got actual content
180+ if hasContent {
181+ logging.Info(fmt.Sprintf("[filtering] Successfully compiled %d network rules from sources", len(merged.NetworkRules)))
182+
183+ // Cache the final result
184+ if err := fm.store.SaveCache(merged); err != nil {
185+ logging.Error(fmt.Sprintf("Failed to save filter cache: %v", err))
186+ }
187+ } else {
188+ logging.Warn("[filtering] No network rules were successfully compiled from any source - keeping existing filters if any")
189+ }
190+}
191+
192+func (fm *FilterManager) downloadAndCompile(ctx context.Context, url string) (*CompiledFilters, error) {
193+ // Create HTTP client with timeout
194+ client := &http.Client{
195+ Timeout: httpTimeoutSeconds * time.Second,
196+ }
197+
198+ // Create request with context
199+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
200+ if err != nil {
201+ return nil, fmt.Errorf("failed to create request for %s: %w", url, err)
202+ }
203+
204+ // Set User-Agent to identify as browser
205+ req.Header.Set("User-Agent", "Dumber Browser/1.0")
206+
207+ // Download filter list
208+ resp, err := client.Do(req)
209+ if err != nil {
210+ return nil, fmt.Errorf("failed to download %s: %w", url, ErrNetworkError)
211+ }
212+ defer func() {
213+ if closeErr := resp.Body.Close(); closeErr != nil {
214+ logging.Error(fmt.Sprintf("Failed to close response body for %s: %v", url, closeErr))
215+ }
216+ }()
217+
218+ if resp.StatusCode != http.StatusOK {
219+ return nil, fmt.Errorf("HTTP %d downloading %s", resp.StatusCode, url)
220+ }
221+
222+ // Create filter converter
223+ filterConverter := converter.NewFilterConverter()
224+ compiled := NewCompiledFilters()
225+
226+ // Parse line by line for memory efficiency
227+ scanner := bufio.NewScanner(resp.Body)
228+ lineCount := 0
229+
230+ for scanner.Scan() {
231+ line := scanner.Text()
232+ lineCount++
233+
234+ // Check context cancellation periodically
235+ if lineCount%contextCheckInterval == 0 {
236+ select {
237+ case <-ctx.Done():
238+ return nil, ctx.Err()
239+ default:
240+ }
241+ }
242+
243+ // Convert each line
244+ if err := filterConverter.ConvertEasyListLine(line); err != nil {
245+ logging.Debug(fmt.Sprintf("Failed to convert filter line: %s, error: %v", line, err))
246+ continue
247+ }
248+ }
249+
250+ if err := scanner.Err(); err != nil {
251+ return nil, fmt.Errorf("error reading filter list %s: %w", url, err)
252+ }
253+
254+ // Get compiled rules from converter
255+ compiled.NetworkRules = filterConverter.GetNetworkRules()
256+ compiled.CosmeticRules = filterConverter.GetCosmeticRules()
257+ compiled.GenericHiding = filterConverter.GetGenericHiding()
258+ compiled.CompiledAt = time.Now()
259+ compiled.Version = fmt.Sprintf("downloaded-%d", time.Now().Unix())
260+
261+ // Log parsing results
262+ logging.Info(fmt.Sprintf("[filtering] Parsed %s: %d lines, %d network rules, %d cosmetic domains, %d generic hiding rules",
263+ url, lineCount, len(compiled.NetworkRules), len(compiled.CosmeticRules), len(compiled.GenericHiding)))
264+
265+ return compiled, nil
266+}
267+
268+func (fm *FilterManager) applyFilters(filters *CompiledFilters) {
269+ fm.compileMutex.Lock()
270+ defer fm.compileMutex.Unlock()
271+
272+ // Add uBlock Origin style scriptlets to handle broken page loading
273+
274+ // Instead of blocking completely, add exception rules for critical ad scripts that break page loading
275+ // This allows the page to load while neutralizing tracking
276+ antiBreakageRules := []converter.WebKitRule{
277+ {
278+ Trigger: converter.Trigger{
279+ URLFilter: "googlesyndication.com",
280+ ResourceType: []string{converter.ResourceTypeScript},
281+ },
282+ Action: converter.Action{
283+ Type: converter.ActionTypeBlock,
284+ },
285+ },
286+ {
287+ Trigger: converter.Trigger{
288+ URLFilter: "doubleclick.net",
289+ },
290+ Action: converter.Action{
291+ Type: converter.ActionTypeBlock,
292+ },
293+ },
294+ {
295+ Trigger: converter.Trigger{
296+ URLFilter: "googletagservices.com/tag/js/gpt.js",
297+ ResourceType: []string{converter.ResourceTypeScript},
298+ },
299+ Action: converter.Action{
300+ Type: converter.ActionTypeBlock,
301+ },
302+ },
303+ }
304+ filters.NetworkRules = append(filters.NetworkRules, antiBreakageRules...)
305+
306+ // Add generic cosmetic rules for common ad loading indicators and infinite loaders
307+ antiLoaderSelectors := []string{
308+ // Generic ad loader patterns (work across all sites)
309+ ".ad-loading", ".ad-placeholder", ".loading-ad", ".ad-spinner",
310+ "[class*='loading'][class*='ad']", "[id*='loading'][id*='ad']",
311+ ".spinner[class*='ad']", "[data-loading*='ad']",
312+ // Generic infinite loader patterns
313+ ".infinite-loader", ".loading-infinite", "[data-loading='infinite']",
314+ ".loader[style*='infinite']", ".spinner[data-infinite='true']",
315+ // Generic advertising loader patterns (multi-language)
316+ ".advertisement-loading", ".ads-loading", ".publicity-loading",
317+ }
318+
319+ // Add generic hiding rules for loader prevention
320+ filters.GenericHiding = append(filters.GenericHiding, antiLoaderSelectors...)
321+
322+ logging.Info(fmt.Sprintf("[filtering] Applying filters: %d network rules (including test rule), %d cosmetic domains, %d generic hiding rules",
323+ len(filters.NetworkRules), len(filters.CosmeticRules), len(filters.GenericHiding)))
324+
325+ fm.compiled = filters
326+
327+ // Inject cosmetic rules
328+ if err := fm.cosmeticInjector.InjectRules(filters.CosmeticRules); err != nil {
329+ logging.Error(fmt.Sprintf("Failed to inject cosmetic rules: %v", err))
330+ }
331+
332+ // Notify that filters are ready
333+ if fm.onFiltersReady != nil {
334+ go fm.onFiltersReady()
335+ }
336+}
337+
338+func (fm *FilterManager) startUpdateLoop(ctx context.Context) {
339+ ticker := time.NewTicker(fm.updateInterval)
340+ defer ticker.Stop()
341+
342+ for {
343+ select {
344+ case <-ctx.Done():
345+ return
346+ case <-ticker.C:
347+ // Check if cache needs update and schedule background update
348+ if exists, lastModified, _ := fm.store.GetCacheInfo(); exists {
349+ if time.Since(lastModified) > fm.updateInterval {
350+ logging.Info("[filtering] Periodic update triggered")
351+ go fm.updateFiltersInBackground(ctx)
352+ }
353+ }
354+ }
355+ }
356+}
357+
358+// updateFiltersInBackground updates filters without clearing existing ones
359+func (fm *FilterManager) updateFiltersInBackground(ctx context.Context) {
360+ logging.Info("[filtering] Starting background filter update")
361+
362+ // Compile new filters
363+ sources := []string{
364+ "https://easylist.to/easylist/easylist.txt",
365+ "https://easylist.to/easylist/easyprivacy.txt",
366+ "https://easylist-downloads.adblockplus.org/liste_fr.txt", // For French sites like lesnumeriques.com
367+ }
368+
369+ var wg sync.WaitGroup
370+ results := make(chan *CompiledFilters, len(sources))
371+
372+ for _, url := range sources {
373+ wg.Add(1)
374+ go func(u string) {
375+ defer wg.Done()
376+ if filters, err := fm.downloadAndCompile(ctx, u); err == nil {
377+ results <- filters
378+ } else {
379+ logging.Warn(fmt.Sprintf("[filtering] Failed to update from %s: %v", u, err))
380+ }
381+ }(url)
382+ }
383+
384+ // Wait for all compilations
385+ go func() {
386+ wg.Wait()
387+ close(results)
388+ }()
389+
390+ // Merge results
391+ merged := NewCompiledFilters()
392+ hasContent := false
393+
394+ for compiled := range results {
395+ if compiled != nil && len(compiled.NetworkRules) > 0 {
396+ hasContent = true
397+ merged.Merge(compiled)
398+ }
399+ }
400+
401+ // Only apply and save if we got actual content
402+ if hasContent {
403+ logging.Info(fmt.Sprintf("[filtering] Background update succeeded with %d network rules", len(merged.NetworkRules)))
404+ fm.applyFilters(merged)
405+
406+ if err := fm.store.SaveCache(merged); err != nil {
407+ logging.Error(fmt.Sprintf("Failed to save updated filter cache: %v", err))
408+ }
409+ } else {
410+ logging.Warn("[filtering] Background update produced no filters, keeping existing filters")
411+ }
412+}
413+
414+// GetNetworkFilters returns the compiled network filters for WebKit
415+func (fm *FilterManager) GetNetworkFilters() ([]byte, error) {
416+ fm.compileMutex.RLock()
417+ defer fm.compileMutex.RUnlock()
418+
419+ if fm.compiled == nil {
420+ return nil, ErrFiltersNotReady
421+ }
422+
423+ json, err := fm.compiled.ToJSON()
424+ if err != nil {
425+ return nil, err
426+ }
427+
428+ return json, nil
429+}
430+
431+// GetCosmeticRulesForDomain returns cosmetic rules for a specific domain
432+func (fm *FilterManager) GetCosmeticRulesForDomain(domain string) []string {
433+ fm.compileMutex.RLock()
434+ defer fm.compileMutex.RUnlock()
435+
436+ if fm.compiled == nil {
437+ return nil
438+ }
439+
440+ return fm.compiled.GetRulesForDomain(domain)
441+}
442+
443+// GetCosmeticScript returns the cosmetic filtering script
444+func (fm *FilterManager) GetCosmeticScript() string {
445+ return fm.cosmeticInjector.GetScriptForDomain("")
446+}
447+
448+// GetCosmeticScriptForDomain returns the cosmetic filtering script for a specific domain
449+func (fm *FilterManager) GetCosmeticScriptForDomain(domain string) string {
450+ return fm.cosmeticInjector.GetScriptForDomain(domain)
451+}
452+
453+// UpdateCosmeticRules adds new cosmetic rules for a domain
454+func (fm *FilterManager) UpdateCosmeticRules(domain string, selectors []string) {
455+ fm.cosmeticInjector.UpdateRulesForDomain(domain, selectors)
456+}
457+
458+// GetCosmeticUpdateScript returns JavaScript to update cosmetic rules dynamically
459+func (fm *FilterManager) GetCosmeticUpdateScript(selectors []string) string {
460+ return fm.cosmeticInjector.GetUpdateScript(selectors)
461+}
462+
463+// GetCosmeticCleanupScript returns JavaScript to cleanup cosmetic filtering
464+func (fm *FilterManager) GetCosmeticCleanupScript() string {
465+ return fm.cosmeticInjector.GetCleanupScript()
466+}
467+
468+// NewCompiledFilters creates a new CompiledFilters instance
469+func NewCompiledFilters() *CompiledFilters {
470+ return &CompiledFilters{
471+ NetworkRules: make([]converter.WebKitRule, 0),
472+ CosmeticRules: make(map[string][]string),
473+ GenericHiding: make([]string, 0),
474+ CompiledAt: time.Now(),
475+ }
476+}
477+
478+// Merge combines two CompiledFilters instances
479+func (cf *CompiledFilters) Merge(other *CompiledFilters) {
480+ cf.mu.Lock()
481+ defer cf.mu.Unlock()
482+
483+ if other == nil {
484+ return
485+ }
486+
487+ other.mu.RLock()
488+ defer other.mu.RUnlock()
489+
490+ // Merge network rules
491+ cf.NetworkRules = append(cf.NetworkRules, other.NetworkRules...)
492+
493+ // Merge cosmetic rules
494+ for domain, rules := range other.CosmeticRules {
495+ cf.CosmeticRules[domain] = append(cf.CosmeticRules[domain], rules...)
496+ }
497+
498+ // Merge generic hiding rules
499+ cf.GenericHiding = append(cf.GenericHiding, other.GenericHiding...)
500+}
501+
502+// GetRulesForDomain returns cosmetic rules for a specific domain
503+func (cf *CompiledFilters) GetRulesForDomain(domain string) []string {
504+ cf.mu.RLock()
505+ defer cf.mu.RUnlock()
506+
507+ rules := make([]string, 0)
508+
509+ // Add generic hiding rules
510+ rules = append(rules, cf.GenericHiding...)
511+
512+ // Add domain-specific rules
513+ if domainRules, exists := cf.CosmeticRules[domain]; exists {
514+ rules = append(rules, domainRules...)
515+ }
516+
517+ return rules
518+}
519+
520+// ToJSON converts network rules to JSON format
521+func (cf *CompiledFilters) ToJSON() ([]byte, error) {
522+ cf.mu.RLock()
523+ defer cf.mu.RUnlock()
524+
525+ return json.Marshal(cf.NetworkRules)
526+}
A internal/filtering/store.go +208 -0
@@ -0,0 +1,208 @@
1+package filtering
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/config"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+const (
16+ cacheVersion = 1 // Cache format version for compatibility
17+ dirPermissions = 0755 // Directory permissions
18+ filePermissions = 0644 // File permissions
19+)
20+
21+// FileFilterStore implements FilterStore using filesystem storage
22+type FileFilterStore struct {
23+ cacheFile string
24+ metaFile string
25+}
26+
27+// CacheMetadata stores information about the cache
28+type CacheMetadata struct {
29+ Version int `json:"version"`
30+ CreatedAt time.Time `json:"created_at"`
31+ LastUsed time.Time `json:"last_used"`
32+ DataHash string `json:"data_hash"`
33+ FilterHashes []string `json:"filter_hashes"` // Hashes of source filter lists
34+}
35+
36+// NewFileFilterStore creates a new file-based filter store
37+func NewFileFilterStore() (*FileFilterStore, error) {
38+ cacheFile, err := config.GetFilterCacheFile()
39+ if err != nil {
40+ return nil, fmt.Errorf("failed to get cache file path: %w", err)
41+ }
42+
43+ cacheDir := filepath.Dir(cacheFile)
44+ metaFile := filepath.Join(cacheDir, "metadata.json")
45+
46+ // Ensure cache directory exists
47+ if err := os.MkdirAll(cacheDir, dirPermissions); err != nil {
48+ return nil, fmt.Errorf("failed to create cache directory: %w", err)
49+ }
50+
51+ return &FileFilterStore{
52+ cacheFile: cacheFile,
53+ metaFile: metaFile,
54+ }, nil
55+}
56+
57+// LoadCached loads compiled filters from cache
58+func (fs *FileFilterStore) LoadCached() (*CompiledFilters, error) {
59+ // Check if cache file exists
60+ if _, err := os.Stat(fs.cacheFile); os.IsNotExist(err) {
61+ return nil, fmt.Errorf("cache file does not exist")
62+ }
63+
64+ // Load metadata
65+ metadata, err := fs.loadMetadata()
66+ if err != nil {
67+ return nil, fmt.Errorf("failed to load cache metadata: %w", err)
68+ }
69+
70+ // Verify cache version compatibility
71+ if metadata.Version != cacheVersion {
72+ return nil, fmt.Errorf("cache version mismatch: got %d, expected %d", metadata.Version, cacheVersion)
73+ }
74+
75+ // Load cache data
76+ data, err := os.ReadFile(fs.cacheFile)
77+ if err != nil {
78+ return nil, fmt.Errorf("failed to read cache file: %w", err)
79+ }
80+
81+ // Verify data integrity
82+ dataHash := fmt.Sprintf("%x", sha256.Sum256(data))
83+ if dataHash != metadata.DataHash {
84+ return nil, fmt.Errorf("cache data corrupted: hash mismatch")
85+ }
86+
87+ // Deserialize compiled filters
88+ var compiled CompiledFilters
89+ if err := json.Unmarshal(data, &compiled); err != nil {
90+ return nil, fmt.Errorf("failed to deserialize cache data: %w", err)
91+ }
92+
93+ // Update last used time
94+ metadata.LastUsed = time.Now()
95+ if err := fs.saveMetadata(metadata); err != nil {
96+ logging.Error(fmt.Sprintf("Failed to update cache metadata: %v", err))
97+ }
98+
99+ logging.Info(fmt.Sprintf("Loaded filter cache from %s", fs.cacheFile))
100+ return &compiled, nil
101+}
102+
103+// SaveCache saves compiled filters to cache
104+func (fs *FileFilterStore) SaveCache(filters *CompiledFilters) error {
105+ // Serialize filters
106+ data, err := json.Marshal(filters)
107+ if err != nil {
108+ return fmt.Errorf("failed to serialize filters: %w", err)
109+ }
110+
111+ // Create metadata
112+ metadata := &CacheMetadata{
113+ Version: cacheVersion,
114+ CreatedAt: time.Now(),
115+ LastUsed: time.Now(),
116+ DataHash: fmt.Sprintf("%x", sha256.Sum256(data)),
117+ // FilterHashes would be populated with source list hashes in a full implementation
118+ FilterHashes: []string{},
119+ }
120+
121+ // Write cache data atomically
122+ tempFile := fs.cacheFile + ".tmp"
123+ if err := os.WriteFile(tempFile, data, filePermissions); err != nil {
124+ return fmt.Errorf("failed to write cache data: %w", err)
125+ }
126+
127+ if err := os.Rename(tempFile, fs.cacheFile); err != nil {
128+ if removeErr := os.Remove(tempFile); removeErr != nil {
129+ logging.Error(fmt.Sprintf("Failed to cleanup temp file %s: %v", tempFile, removeErr))
130+ }
131+ return fmt.Errorf("failed to move cache file: %w", err)
132+ }
133+
134+ // Save metadata
135+ if err := fs.saveMetadata(metadata); err != nil {
136+ return fmt.Errorf("failed to save cache metadata: %w", err)
137+ }
138+
139+ logging.Info(fmt.Sprintf("Saved filter cache to %s", fs.cacheFile))
140+ return nil
141+}
142+
143+// GetCacheInfo returns cache existence and modification time
144+func (fs *FileFilterStore) GetCacheInfo() (bool, time.Time, error) {
145+ info, err := os.Stat(fs.cacheFile)
146+ if os.IsNotExist(err) {
147+ return false, time.Time{}, nil
148+ }
149+ if err != nil {
150+ return false, time.Time{}, err
151+ }
152+
153+ return true, info.ModTime(), nil
154+}
155+
156+// InvalidateCache removes the cache file
157+func (fs *FileFilterStore) InvalidateCache() error {
158+ // Remove cache file
159+ if err := os.Remove(fs.cacheFile); err != nil && !os.IsNotExist(err) {
160+ return fmt.Errorf("failed to remove cache file: %w", err)
161+ }
162+
163+ // Remove metadata file
164+ if err := os.Remove(fs.metaFile); err != nil && !os.IsNotExist(err) {
165+ return fmt.Errorf("failed to remove metadata file: %w", err)
166+ }
167+
168+ logging.Info("Filter cache invalidated")
169+ return nil
170+}
171+
172+// loadMetadata loads cache metadata from file
173+func (fs *FileFilterStore) loadMetadata() (*CacheMetadata, error) {
174+ data, err := os.ReadFile(fs.metaFile)
175+ if err != nil {
176+ return nil, err
177+ }
178+
179+ var metadata CacheMetadata
180+ if err := json.Unmarshal(data, &metadata); err != nil {
181+ return nil, err
182+ }
183+
184+ return &metadata, nil
185+}
186+
187+// saveMetadata saves cache metadata to file
188+func (fs *FileFilterStore) saveMetadata(metadata *CacheMetadata) error {
189+ data, err := json.Marshal(metadata)
190+ if err != nil {
191+ return err
192+ }
193+
194+ // Write metadata atomically
195+ tempFile := fs.metaFile + ".tmp"
196+ if err := os.WriteFile(tempFile, data, filePermissions); err != nil {
197+ return err
198+ }
199+
200+ if err := os.Rename(tempFile, fs.metaFile); err != nil {
201+ if removeErr := os.Remove(tempFile); removeErr != nil {
202+ logging.Error(fmt.Sprintf("Failed to cleanup temp metadata file %s: %v", tempFile, removeErr))
203+ }
204+ return err
205+ }
206+
207+ return nil
208+}
@@ -0,0 +1,208 @@
1+package filtering
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/json"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/config"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+const (
16+ cacheVersion = 1 // Cache format version for compatibility
17+ dirPermissions = 0755 // Directory permissions
18+ filePermissions = 0644 // File permissions
19+)
20+
21+// FileFilterStore implements FilterStore using filesystem storage
22+type FileFilterStore struct {
23+ cacheFile string
24+ metaFile string
25+}
26+
27+// CacheMetadata stores information about the cache
28+type CacheMetadata struct {
29+ Version int `json:"version"`
30+ CreatedAt time.Time `json:"created_at"`
31+ LastUsed time.Time `json:"last_used"`
32+ DataHash string `json:"data_hash"`
33+ FilterHashes []string `json:"filter_hashes"` // Hashes of source filter lists
34+}
35+
36+// NewFileFilterStore creates a new file-based filter store
37+func NewFileFilterStore() (*FileFilterStore, error) {
38+ cacheFile, err := config.GetFilterCacheFile()
39+ if err != nil {
40+ return nil, fmt.Errorf("failed to get cache file path: %w", err)
41+ }
42+
43+ cacheDir := filepath.Dir(cacheFile)
44+ metaFile := filepath.Join(cacheDir, "metadata.json")
45+
46+ // Ensure cache directory exists
47+ if err := os.MkdirAll(cacheDir, dirPermissions); err != nil {
48+ return nil, fmt.Errorf("failed to create cache directory: %w", err)
49+ }
50+
51+ return &FileFilterStore{
52+ cacheFile: cacheFile,
53+ metaFile: metaFile,
54+ }, nil
55+}
56+
57+// LoadCached loads compiled filters from cache
58+func (fs *FileFilterStore) LoadCached() (*CompiledFilters, error) {
59+ // Check if cache file exists
60+ if _, err := os.Stat(fs.cacheFile); os.IsNotExist(err) {
61+ return nil, fmt.Errorf("cache file does not exist")
62+ }
63+
64+ // Load metadata
65+ metadata, err := fs.loadMetadata()
66+ if err != nil {
67+ return nil, fmt.Errorf("failed to load cache metadata: %w", err)
68+ }
69+
70+ // Verify cache version compatibility
71+ if metadata.Version != cacheVersion {
72+ return nil, fmt.Errorf("cache version mismatch: got %d, expected %d", metadata.Version, cacheVersion)
73+ }
74+
75+ // Load cache data
76+ data, err := os.ReadFile(fs.cacheFile)
77+ if err != nil {
78+ return nil, fmt.Errorf("failed to read cache file: %w", err)
79+ }
80+
81+ // Verify data integrity
82+ dataHash := fmt.Sprintf("%x", sha256.Sum256(data))
83+ if dataHash != metadata.DataHash {
84+ return nil, fmt.Errorf("cache data corrupted: hash mismatch")
85+ }
86+
87+ // Deserialize compiled filters
88+ var compiled CompiledFilters
89+ if err := json.Unmarshal(data, &compiled); err != nil {
90+ return nil, fmt.Errorf("failed to deserialize cache data: %w", err)
91+ }
92+
93+ // Update last used time
94+ metadata.LastUsed = time.Now()
95+ if err := fs.saveMetadata(metadata); err != nil {
96+ logging.Error(fmt.Sprintf("Failed to update cache metadata: %v", err))
97+ }
98+
99+ logging.Info(fmt.Sprintf("Loaded filter cache from %s", fs.cacheFile))
100+ return &compiled, nil
101+}
102+
103+// SaveCache saves compiled filters to cache
104+func (fs *FileFilterStore) SaveCache(filters *CompiledFilters) error {
105+ // Serialize filters
106+ data, err := json.Marshal(filters)
107+ if err != nil {
108+ return fmt.Errorf("failed to serialize filters: %w", err)
109+ }
110+
111+ // Create metadata
112+ metadata := &CacheMetadata{
113+ Version: cacheVersion,
114+ CreatedAt: time.Now(),
115+ LastUsed: time.Now(),
116+ DataHash: fmt.Sprintf("%x", sha256.Sum256(data)),
117+ // FilterHashes would be populated with source list hashes in a full implementation
118+ FilterHashes: []string{},
119+ }
120+
121+ // Write cache data atomically
122+ tempFile := fs.cacheFile + ".tmp"
123+ if err := os.WriteFile(tempFile, data, filePermissions); err != nil {
124+ return fmt.Errorf("failed to write cache data: %w", err)
125+ }
126+
127+ if err := os.Rename(tempFile, fs.cacheFile); err != nil {
128+ if removeErr := os.Remove(tempFile); removeErr != nil {
129+ logging.Error(fmt.Sprintf("Failed to cleanup temp file %s: %v", tempFile, removeErr))
130+ }
131+ return fmt.Errorf("failed to move cache file: %w", err)
132+ }
133+
134+ // Save metadata
135+ if err := fs.saveMetadata(metadata); err != nil {
136+ return fmt.Errorf("failed to save cache metadata: %w", err)
137+ }
138+
139+ logging.Info(fmt.Sprintf("Saved filter cache to %s", fs.cacheFile))
140+ return nil
141+}
142+
143+// GetCacheInfo returns cache existence and modification time
144+func (fs *FileFilterStore) GetCacheInfo() (bool, time.Time, error) {
145+ info, err := os.Stat(fs.cacheFile)
146+ if os.IsNotExist(err) {
147+ return false, time.Time{}, nil
148+ }
149+ if err != nil {
150+ return false, time.Time{}, err
151+ }
152+
153+ return true, info.ModTime(), nil
154+}
155+
156+// InvalidateCache removes the cache file
157+func (fs *FileFilterStore) InvalidateCache() error {
158+ // Remove cache file
159+ if err := os.Remove(fs.cacheFile); err != nil && !os.IsNotExist(err) {
160+ return fmt.Errorf("failed to remove cache file: %w", err)
161+ }
162+
163+ // Remove metadata file
164+ if err := os.Remove(fs.metaFile); err != nil && !os.IsNotExist(err) {
165+ return fmt.Errorf("failed to remove metadata file: %w", err)
166+ }
167+
168+ logging.Info("Filter cache invalidated")
169+ return nil
170+}
171+
172+// loadMetadata loads cache metadata from file
173+func (fs *FileFilterStore) loadMetadata() (*CacheMetadata, error) {
174+ data, err := os.ReadFile(fs.metaFile)
175+ if err != nil {
176+ return nil, err
177+ }
178+
179+ var metadata CacheMetadata
180+ if err := json.Unmarshal(data, &metadata); err != nil {
181+ return nil, err
182+ }
183+
184+ return &metadata, nil
185+}
186+
187+// saveMetadata saves cache metadata to file
188+func (fs *FileFilterStore) saveMetadata(metadata *CacheMetadata) error {
189+ data, err := json.Marshal(metadata)
190+ if err != nil {
191+ return err
192+ }
193+
194+ // Write metadata atomically
195+ tempFile := fs.metaFile + ".tmp"
196+ if err := os.WriteFile(tempFile, data, filePermissions); err != nil {
197+ return err
198+ }
199+
200+ if err := os.Rename(tempFile, fs.metaFile); err != nil {
201+ if removeErr := os.Remove(tempFile); removeErr != nil {
202+ logging.Error(fmt.Sprintf("Failed to cleanup temp metadata file %s: %v", tempFile, removeErr))
203+ }
204+ return err
205+ }
206+
207+ return nil
208+}
A internal/filtering/updater.go +476 -0
@@ -0,0 +1,476 @@
1+package filtering
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "fmt"
7+ "net/http"
8+ "strings"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+const (
16+ updateTimeoutSeconds = 60 // HTTP timeout for update checks
17+ maxConcurrentUpdates = 4 // Maximum concurrent filter list downloads
18+)
19+
20+// FilterUpdater manages differential filter list updates
21+type FilterUpdater struct {
22+ manager *FilterManager
23+ httpClient *http.Client
24+ diffEngine *DiffEngine
25+ updateMutex sync.Mutex
26+}
27+
28+// FilterUpdate represents an update to a filter list
29+type FilterUpdate struct {
30+ ListID string
31+ URL string
32+ Previous []byte
33+ Current []byte
34+ Hash string
35+ Compiled *CompiledFilters
36+}
37+
38+// FilterListInfo stores metadata about a filter list
39+type FilterListInfo struct {
40+ URL string
41+ LastModified time.Time
42+ ETag string
43+ ContentHash string
44+ Size int64
45+}
46+
47+// DiffEngine handles differential updates between filter lists
48+type DiffEngine struct {
49+ // We'll implement a simple line-based diff for filter lists
50+}
51+
52+// NewFilterUpdater creates a new filter updater
53+func NewFilterUpdater(manager *FilterManager) *FilterUpdater {
54+ return &FilterUpdater{
55+ manager: manager,
56+ httpClient: &http.Client{
57+ Timeout: updateTimeoutSeconds * time.Second,
58+ },
59+ diffEngine: &DiffEngine{},
60+ }
61+}
62+
63+// CheckAndUpdate checks for filter list updates and applies them
64+func (fu *FilterUpdater) CheckAndUpdate(ctx context.Context) error {
65+ fu.updateMutex.Lock()
66+ defer fu.updateMutex.Unlock()
67+
68+ sources := []string{
69+ "https://easylist.to/easylist/easylist.txt",
70+ "https://easylist.to/easylist/easyprivacy.txt",
71+ }
72+
73+ // Check for updates concurrently
74+ updates := make(chan *FilterUpdate, len(sources))
75+ var wg sync.WaitGroup
76+
77+ // Limit concurrent updates
78+ semaphore := make(chan struct{}, maxConcurrentUpdates)
79+
80+ for _, url := range sources {
81+ wg.Add(1)
82+ go func(u string) {
83+ defer wg.Done()
84+
85+ // Acquire semaphore
86+ semaphore <- struct{}{}
87+ defer func() { <-semaphore }()
88+
89+ update, err := fu.checkSingleUpdate(ctx, u)
90+ if err != nil {
91+ logging.Error(fmt.Sprintf("Failed to check update for %s: %v", u, err))
92+ return
93+ }
94+
95+ if update != nil {
96+ updates <- update
97+ }
98+ }(url)
99+ }
100+
101+ // Wait for all checks to complete
102+ go func() {
103+ wg.Wait()
104+ close(updates)
105+ }()
106+
107+ // Apply updates as they come in
108+ updateCount := 0
109+ for update := range updates {
110+ if err := fu.applyUpdate(update); err != nil {
111+ logging.Error(fmt.Sprintf("Failed to apply update for %s: %v", update.URL, err))
112+ continue
113+ }
114+ updateCount++
115+ }
116+
117+ if updateCount > 0 {
118+ logging.Info(fmt.Sprintf("Applied %d filter list updates", updateCount))
119+ } else {
120+ logging.Debug("No filter list updates available")
121+ }
122+
123+ return nil
124+}
125+
126+// checkSingleUpdate checks if a single filter list needs updating
127+func (fu *FilterUpdater) checkSingleUpdate(ctx context.Context, url string) (*FilterUpdate, error) {
128+ // Create HEAD request to check if content has changed
129+ req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
130+ if err != nil {
131+ return nil, fmt.Errorf("failed to create HEAD request: %w", err)
132+ }
133+
134+ // Add headers to support conditional requests
135+ req.Header.Set("User-Agent", "Dumber Browser/1.0 Filter Updater")
136+
137+ resp, err := fu.httpClient.Do(req)
138+ if err != nil {
139+ return nil, fmt.Errorf("HEAD request failed: %w", err)
140+ }
141+ defer resp.Body.Close()
142+
143+ if resp.StatusCode != http.StatusOK {
144+ return nil, fmt.Errorf("HEAD request returned %d", resp.StatusCode)
145+ }
146+
147+ // Get metadata from response headers
148+ lastModified := resp.Header.Get("Last-Modified")
149+ etag := resp.Header.Get("ETag")
150+ contentLength := resp.ContentLength
151+
152+ // Check if we need to download full content
153+ needsUpdate := fu.needsUpdate(url, lastModified, etag, contentLength)
154+ if !needsUpdate {
155+ return nil, nil // No update needed
156+ }
157+
158+ // Download the full content
159+ return fu.downloadUpdate(ctx, url, lastModified, etag)
160+}
161+
162+// needsUpdate determines if a filter list needs updating based on metadata
163+func (fu *FilterUpdater) needsUpdate(url, lastModified, etag string, contentLength int64) bool {
164+ // For now, we'll implement a simple time-based check
165+ // In a full implementation, this would check against stored metadata
166+
167+ // Always check for updates if we don't have cached metadata
168+ // In practice, this would compare with stored filter list metadata
169+ return true
170+}
171+
172+// downloadUpdate downloads the updated filter list and creates a diff
173+func (fu *FilterUpdater) downloadUpdate(ctx context.Context, url, lastModified, etag string) (*FilterUpdate, error) {
174+ // Download current content
175+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
176+ if err != nil {
177+ return nil, fmt.Errorf("failed to create GET request: %w", err)
178+ }
179+
180+ req.Header.Set("User-Agent", "Dumber Browser/1.0 Filter Updater")
181+
182+ resp, err := fu.httpClient.Do(req)
183+ if err != nil {
184+ return nil, fmt.Errorf("GET request failed: %w", err)
185+ }
186+ defer resp.Body.Close()
187+
188+ if resp.StatusCode != http.StatusOK {
189+ return nil, fmt.Errorf("GET request returned %d", resp.StatusCode)
190+ }
191+
192+ // Read content
193+ currentContent := make([]byte, 0, resp.ContentLength)
194+ buffer := make([]byte, 8192)
195+
196+ for {
197+ select {
198+ case <-ctx.Done():
199+ return nil, ctx.Err()
200+ default:
201+ }
202+
203+ n, err := resp.Body.Read(buffer)
204+ if n > 0 {
205+ currentContent = append(currentContent, buffer[:n]...)
206+ }
207+ if err != nil {
208+ break
209+ }
210+ }
211+
212+ // Calculate content hash
213+ hash := fmt.Sprintf("%x", sha256.Sum256(currentContent))
214+
215+ // Create update object
216+ update := &FilterUpdate{
217+ ListID: fu.getListID(url),
218+ URL: url,
219+ Current: currentContent,
220+ Hash: hash,
221+ }
222+
223+ // Get previous version for diffing (if available)
224+ previous := fu.getPreviousContent(update.ListID)
225+ if previous != nil {
226+ update.Previous = previous
227+
228+ // Check if content actually changed
229+ if fu.contentEqual(previous, currentContent) {
230+ logging.Debug(fmt.Sprintf("No content changes in %s", url))
231+ return nil, nil
232+ }
233+ }
234+
235+ // Compile the differential update
236+ compiled, err := fu.compileDiff(update)
237+ if err != nil {
238+ return nil, fmt.Errorf("failed to compile diff: %w", err)
239+ }
240+
241+ update.Compiled = compiled
242+ return update, nil
243+}
244+
245+// compileDiff compiles only the changed parts of a filter list
246+func (fu *FilterUpdater) compileDiff(update *FilterUpdate) (*CompiledFilters, error) {
247+ if update.Previous == nil {
248+ // No previous version, compile everything
249+ return fu.compileComplete(update.Current)
250+ }
251+
252+ // Find differences between old and new content
253+ added, removed := fu.diffEngine.Diff(update.Previous, update.Current)
254+
255+ compiled := NewCompiledFilters()
256+
257+ // Process added lines
258+ for _, line := range added {
259+ rule, err := fu.parseFilterLine(line)
260+ if err != nil {
261+ logging.Debug(fmt.Sprintf("Failed to parse added line: %s, error: %v", line, err))
262+ continue
263+ }
264+ if rule != nil {
265+ fu.addRuleToCompiled(compiled, rule)
266+ }
267+ }
268+
269+ // Process removed lines (mark for removal)
270+ for _, line := range removed {
271+ rule, err := fu.parseFilterLine(line)
272+ if err != nil {
273+ logging.Debug(fmt.Sprintf("Failed to parse removed line: %s, error: %v", line, err))
274+ continue
275+ }
276+ if rule != nil {
277+ fu.removeRuleFromCompiled(compiled, rule)
278+ }
279+ }
280+
281+ compiled.Version = fmt.Sprintf("diff-update-%d", time.Now().Unix())
282+ compiled.CompiledAt = time.Now()
283+
284+ logging.Info(fmt.Sprintf("Compiled differential update: %d rules added/removed for %s",
285+ len(added)+len(removed), update.URL))
286+
287+ return compiled, nil
288+}
289+
290+// compileComplete compiles a complete filter list
291+func (fu *FilterUpdater) compileComplete(content []byte) (*CompiledFilters, error) {
292+ // Use existing manager's compiler functionality
293+ if fu.manager.compiler != nil {
294+ return fu.manager.compiler.CompileFromData(content)
295+ }
296+
297+ // Fallback: manual compilation
298+ return fu.compileManually(content)
299+}
300+
301+// compileManually manually compiles filter content when no compiler is available
302+func (fu *FilterUpdater) compileManually(content []byte) (*CompiledFilters, error) {
303+ compiled := NewCompiledFilters()
304+
305+ lines := strings.Split(string(content), "\n")
306+ for _, line := range lines {
307+ rule, err := fu.parseFilterLine(strings.TrimSpace(line))
308+ if err != nil {
309+ continue // Skip invalid lines
310+ }
311+ if rule != nil {
312+ fu.addRuleToCompiled(compiled, rule)
313+ }
314+ }
315+
316+ compiled.Version = fmt.Sprintf("manual-compile-%d", time.Now().Unix())
317+ compiled.CompiledAt = time.Now()
318+
319+ return compiled, nil
320+}
321+
322+// parseFilterLine parses a single filter line (simplified implementation)
323+func (fu *FilterUpdater) parseFilterLine(line string) (interface{}, error) {
324+ line = strings.TrimSpace(line)
325+
326+ // Skip empty lines and comments
327+ if line == "" || strings.HasPrefix(line, "!") {
328+ return nil, nil
329+ }
330+
331+ // This is a simplified parser - in reality, you'd use the converter package
332+ return map[string]string{
333+ "type": "filter",
334+ "line": line,
335+ }, nil
336+}
337+
338+// addRuleToCompiled adds a rule to compiled filters
339+func (fu *FilterUpdater) addRuleToCompiled(compiled *CompiledFilters, rule interface{}) {
340+ // Simplified implementation - in reality, this would properly categorize rules
341+ ruleMap, ok := rule.(map[string]string)
342+ if !ok {
343+ return
344+ }
345+
346+ line := ruleMap["line"]
347+ if strings.Contains(line, "##") {
348+ // Cosmetic filter
349+ parts := strings.SplitN(line, "##", 2)
350+ if len(parts) == 2 {
351+ domain := parts[0]
352+ selector := parts[1]
353+
354+ if domain == "" {
355+ compiled.GenericHiding = append(compiled.GenericHiding, selector)
356+ } else {
357+ if compiled.CosmeticRules == nil {
358+ compiled.CosmeticRules = make(map[string][]string)
359+ }
360+ compiled.CosmeticRules[domain] = append(compiled.CosmeticRules[domain], selector)
361+ }
362+ }
363+ }
364+ // Network filters would be handled here too
365+}
366+
367+// removeRuleFromCompiled marks a rule for removal
368+func (fu *FilterUpdater) removeRuleFromCompiled(compiled *CompiledFilters, rule interface{}) {
369+ // Implementation would mark rules for removal
370+ // For now, we'll just log the removal
371+ ruleMap, ok := rule.(map[string]string)
372+ if ok {
373+ logging.Debug(fmt.Sprintf("Marking rule for removal: %s", ruleMap["line"]))
374+ }
375+}
376+
377+// applyUpdate applies a compiled filter update to the manager
378+func (fu *FilterUpdater) applyUpdate(update *FilterUpdate) error {
379+ if update.Compiled == nil {
380+ return fmt.Errorf("no compiled filters in update")
381+ }
382+
383+ // Apply the update to the filter manager
384+ fu.manager.compileMutex.Lock()
385+ defer fu.manager.compileMutex.Unlock()
386+
387+ // Merge the update with existing filters
388+ if fu.manager.compiled == nil {
389+ fu.manager.compiled = update.Compiled
390+ } else {
391+ fu.manager.compiled.Merge(update.Compiled)
392+ }
393+
394+ // Update cosmetic rules
395+ if err := fu.manager.cosmeticInjector.InjectRules(update.Compiled.CosmeticRules); err != nil {
396+ return fmt.Errorf("failed to inject cosmetic rules: %w", err)
397+ }
398+
399+ // Store the updated content for future diffs
400+ fu.storePreviousContent(update.ListID, update.Current)
401+
402+ logging.Info(fmt.Sprintf("Applied filter update for %s", update.URL))
403+ return nil
404+}
405+
406+// getListID generates a unique ID for a filter list URL
407+func (fu *FilterUpdater) getListID(url string) string {
408+ hash := sha256.Sum256([]byte(url))
409+ return fmt.Sprintf("%x", hash)[:16]
410+}
411+
412+// getPreviousContent retrieves previously stored filter list content
413+func (fu *FilterUpdater) getPreviousContent(listID string) []byte {
414+ // This would typically read from persistent storage
415+ // For now, return nil (no previous content)
416+ return nil
417+}
418+
419+// storePreviousContent stores filter list content for future diff operations
420+func (fu *FilterUpdater) storePreviousContent(listID string, content []byte) {
421+ // This would typically write to persistent storage
422+ // For now, we'll just log the action
423+ logging.Debug(fmt.Sprintf("Storing %d bytes for list %s", len(content), listID))
424+}
425+
426+// contentEqual checks if two byte slices contain the same content
427+func (fu *FilterUpdater) contentEqual(a, b []byte) bool {
428+ if len(a) != len(b) {
429+ return false
430+ }
431+
432+ hashA := sha256.Sum256(a)
433+ hashB := sha256.Sum256(b)
434+
435+ return hashA == hashB
436+}
437+
438+// Diff finds added and removed lines between two versions
439+func (de *DiffEngine) Diff(previous, current []byte) (added []string, removed []string) {
440+ prevLines := strings.Split(string(previous), "\n")
441+ currLines := strings.Split(string(current), "\n")
442+
443+ // Create sets for efficient lookup
444+ prevSet := make(map[string]bool)
445+ currSet := make(map[string]bool)
446+
447+ for _, line := range prevLines {
448+ line = strings.TrimSpace(line)
449+ if line != "" {
450+ prevSet[line] = true
451+ }
452+ }
453+
454+ for _, line := range currLines {
455+ line = strings.TrimSpace(line)
456+ if line != "" {
457+ currSet[line] = true
458+ }
459+ }
460+
461+ // Find added lines (in current but not in previous)
462+ for line := range currSet {
463+ if !prevSet[line] {
464+ added = append(added, line)
465+ }
466+ }
467+
468+ // Find removed lines (in previous but not in current)
469+ for line := range prevSet {
470+ if !currSet[line] {
471+ removed = append(removed, line)
472+ }
473+ }
474+
475+ return added, removed
476+}
@@ -0,0 +1,476 @@
1+package filtering
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "fmt"
7+ "net/http"
8+ "strings"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+const (
16+ updateTimeoutSeconds = 60 // HTTP timeout for update checks
17+ maxConcurrentUpdates = 4 // Maximum concurrent filter list downloads
18+)
19+
20+// FilterUpdater manages differential filter list updates
21+type FilterUpdater struct {
22+ manager *FilterManager
23+ httpClient *http.Client
24+ diffEngine *DiffEngine
25+ updateMutex sync.Mutex
26+}
27+
28+// FilterUpdate represents an update to a filter list
29+type FilterUpdate struct {
30+ ListID string
31+ URL string
32+ Previous []byte
33+ Current []byte
34+ Hash string
35+ Compiled *CompiledFilters
36+}
37+
38+// FilterListInfo stores metadata about a filter list
39+type FilterListInfo struct {
40+ URL string
41+ LastModified time.Time
42+ ETag string
43+ ContentHash string
44+ Size int64
45+}
46+
47+// DiffEngine handles differential updates between filter lists
48+type DiffEngine struct {
49+ // We'll implement a simple line-based diff for filter lists
50+}
51+
52+// NewFilterUpdater creates a new filter updater
53+func NewFilterUpdater(manager *FilterManager) *FilterUpdater {
54+ return &FilterUpdater{
55+ manager: manager,
56+ httpClient: &http.Client{
57+ Timeout: updateTimeoutSeconds * time.Second,
58+ },
59+ diffEngine: &DiffEngine{},
60+ }
61+}
62+
63+// CheckAndUpdate checks for filter list updates and applies them
64+func (fu *FilterUpdater) CheckAndUpdate(ctx context.Context) error {
65+ fu.updateMutex.Lock()
66+ defer fu.updateMutex.Unlock()
67+
68+ sources := []string{
69+ "https://easylist.to/easylist/easylist.txt",
70+ "https://easylist.to/easylist/easyprivacy.txt",
71+ }
72+
73+ // Check for updates concurrently
74+ updates := make(chan *FilterUpdate, len(sources))
75+ var wg sync.WaitGroup
76+
77+ // Limit concurrent updates
78+ semaphore := make(chan struct{}, maxConcurrentUpdates)
79+
80+ for _, url := range sources {
81+ wg.Add(1)
82+ go func(u string) {
83+ defer wg.Done()
84+
85+ // Acquire semaphore
86+ semaphore <- struct{}{}
87+ defer func() { <-semaphore }()
88+
89+ update, err := fu.checkSingleUpdate(ctx, u)
90+ if err != nil {
91+ logging.Error(fmt.Sprintf("Failed to check update for %s: %v", u, err))
92+ return
93+ }
94+
95+ if update != nil {
96+ updates <- update
97+ }
98+ }(url)
99+ }
100+
101+ // Wait for all checks to complete
102+ go func() {
103+ wg.Wait()
104+ close(updates)
105+ }()
106+
107+ // Apply updates as they come in
108+ updateCount := 0
109+ for update := range updates {
110+ if err := fu.applyUpdate(update); err != nil {
111+ logging.Error(fmt.Sprintf("Failed to apply update for %s: %v", update.URL, err))
112+ continue
113+ }
114+ updateCount++
115+ }
116+
117+ if updateCount > 0 {
118+ logging.Info(fmt.Sprintf("Applied %d filter list updates", updateCount))
119+ } else {
120+ logging.Debug("No filter list updates available")
121+ }
122+
123+ return nil
124+}
125+
126+// checkSingleUpdate checks if a single filter list needs updating
127+func (fu *FilterUpdater) checkSingleUpdate(ctx context.Context, url string) (*FilterUpdate, error) {
128+ // Create HEAD request to check if content has changed
129+ req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
130+ if err != nil {
131+ return nil, fmt.Errorf("failed to create HEAD request: %w", err)
132+ }
133+
134+ // Add headers to support conditional requests
135+ req.Header.Set("User-Agent", "Dumber Browser/1.0 Filter Updater")
136+
137+ resp, err := fu.httpClient.Do(req)
138+ if err != nil {
139+ return nil, fmt.Errorf("HEAD request failed: %w", err)
140+ }
141+ defer resp.Body.Close()
142+
143+ if resp.StatusCode != http.StatusOK {
144+ return nil, fmt.Errorf("HEAD request returned %d", resp.StatusCode)
145+ }
146+
147+ // Get metadata from response headers
148+ lastModified := resp.Header.Get("Last-Modified")
149+ etag := resp.Header.Get("ETag")
150+ contentLength := resp.ContentLength
151+
152+ // Check if we need to download full content
153+ needsUpdate := fu.needsUpdate(url, lastModified, etag, contentLength)
154+ if !needsUpdate {
155+ return nil, nil // No update needed
156+ }
157+
158+ // Download the full content
159+ return fu.downloadUpdate(ctx, url, lastModified, etag)
160+}
161+
162+// needsUpdate determines if a filter list needs updating based on metadata
163+func (fu *FilterUpdater) needsUpdate(url, lastModified, etag string, contentLength int64) bool {
164+ // For now, we'll implement a simple time-based check
165+ // In a full implementation, this would check against stored metadata
166+
167+ // Always check for updates if we don't have cached metadata
168+ // In practice, this would compare with stored filter list metadata
169+ return true
170+}
171+
172+// downloadUpdate downloads the updated filter list and creates a diff
173+func (fu *FilterUpdater) downloadUpdate(ctx context.Context, url, lastModified, etag string) (*FilterUpdate, error) {
174+ // Download current content
175+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
176+ if err != nil {
177+ return nil, fmt.Errorf("failed to create GET request: %w", err)
178+ }
179+
180+ req.Header.Set("User-Agent", "Dumber Browser/1.0 Filter Updater")
181+
182+ resp, err := fu.httpClient.Do(req)
183+ if err != nil {
184+ return nil, fmt.Errorf("GET request failed: %w", err)
185+ }
186+ defer resp.Body.Close()
187+
188+ if resp.StatusCode != http.StatusOK {
189+ return nil, fmt.Errorf("GET request returned %d", resp.StatusCode)
190+ }
191+
192+ // Read content
193+ currentContent := make([]byte, 0, resp.ContentLength)
194+ buffer := make([]byte, 8192)
195+
196+ for {
197+ select {
198+ case <-ctx.Done():
199+ return nil, ctx.Err()
200+ default:
201+ }
202+
203+ n, err := resp.Body.Read(buffer)
204+ if n > 0 {
205+ currentContent = append(currentContent, buffer[:n]...)
206+ }
207+ if err != nil {
208+ break
209+ }
210+ }
211+
212+ // Calculate content hash
213+ hash := fmt.Sprintf("%x", sha256.Sum256(currentContent))
214+
215+ // Create update object
216+ update := &FilterUpdate{
217+ ListID: fu.getListID(url),
218+ URL: url,
219+ Current: currentContent,
220+ Hash: hash,
221+ }
222+
223+ // Get previous version for diffing (if available)
224+ previous := fu.getPreviousContent(update.ListID)
225+ if previous != nil {
226+ update.Previous = previous
227+
228+ // Check if content actually changed
229+ if fu.contentEqual(previous, currentContent) {
230+ logging.Debug(fmt.Sprintf("No content changes in %s", url))
231+ return nil, nil
232+ }
233+ }
234+
235+ // Compile the differential update
236+ compiled, err := fu.compileDiff(update)
237+ if err != nil {
238+ return nil, fmt.Errorf("failed to compile diff: %w", err)
239+ }
240+
241+ update.Compiled = compiled
242+ return update, nil
243+}
244+
245+// compileDiff compiles only the changed parts of a filter list
246+func (fu *FilterUpdater) compileDiff(update *FilterUpdate) (*CompiledFilters, error) {
247+ if update.Previous == nil {
248+ // No previous version, compile everything
249+ return fu.compileComplete(update.Current)
250+ }
251+
252+ // Find differences between old and new content
253+ added, removed := fu.diffEngine.Diff(update.Previous, update.Current)
254+
255+ compiled := NewCompiledFilters()
256+
257+ // Process added lines
258+ for _, line := range added {
259+ rule, err := fu.parseFilterLine(line)
260+ if err != nil {
261+ logging.Debug(fmt.Sprintf("Failed to parse added line: %s, error: %v", line, err))
262+ continue
263+ }
264+ if rule != nil {
265+ fu.addRuleToCompiled(compiled, rule)
266+ }
267+ }
268+
269+ // Process removed lines (mark for removal)
270+ for _, line := range removed {
271+ rule, err := fu.parseFilterLine(line)
272+ if err != nil {
273+ logging.Debug(fmt.Sprintf("Failed to parse removed line: %s, error: %v", line, err))
274+ continue
275+ }
276+ if rule != nil {
277+ fu.removeRuleFromCompiled(compiled, rule)
278+ }
279+ }
280+
281+ compiled.Version = fmt.Sprintf("diff-update-%d", time.Now().Unix())
282+ compiled.CompiledAt = time.Now()
283+
284+ logging.Info(fmt.Sprintf("Compiled differential update: %d rules added/removed for %s",
285+ len(added)+len(removed), update.URL))
286+
287+ return compiled, nil
288+}
289+
290+// compileComplete compiles a complete filter list
291+func (fu *FilterUpdater) compileComplete(content []byte) (*CompiledFilters, error) {
292+ // Use existing manager's compiler functionality
293+ if fu.manager.compiler != nil {
294+ return fu.manager.compiler.CompileFromData(content)
295+ }
296+
297+ // Fallback: manual compilation
298+ return fu.compileManually(content)
299+}
300+
301+// compileManually manually compiles filter content when no compiler is available
302+func (fu *FilterUpdater) compileManually(content []byte) (*CompiledFilters, error) {
303+ compiled := NewCompiledFilters()
304+
305+ lines := strings.Split(string(content), "\n")
306+ for _, line := range lines {
307+ rule, err := fu.parseFilterLine(strings.TrimSpace(line))
308+ if err != nil {
309+ continue // Skip invalid lines
310+ }
311+ if rule != nil {
312+ fu.addRuleToCompiled(compiled, rule)
313+ }
314+ }
315+
316+ compiled.Version = fmt.Sprintf("manual-compile-%d", time.Now().Unix())
317+ compiled.CompiledAt = time.Now()
318+
319+ return compiled, nil
320+}
321+
322+// parseFilterLine parses a single filter line (simplified implementation)
323+func (fu *FilterUpdater) parseFilterLine(line string) (interface{}, error) {
324+ line = strings.TrimSpace(line)
325+
326+ // Skip empty lines and comments
327+ if line == "" || strings.HasPrefix(line, "!") {
328+ return nil, nil
329+ }
330+
331+ // This is a simplified parser - in reality, you'd use the converter package
332+ return map[string]string{
333+ "type": "filter",
334+ "line": line,
335+ }, nil
336+}
337+
338+// addRuleToCompiled adds a rule to compiled filters
339+func (fu *FilterUpdater) addRuleToCompiled(compiled *CompiledFilters, rule interface{}) {
340+ // Simplified implementation - in reality, this would properly categorize rules
341+ ruleMap, ok := rule.(map[string]string)
342+ if !ok {
343+ return
344+ }
345+
346+ line := ruleMap["line"]
347+ if strings.Contains(line, "##") {
348+ // Cosmetic filter
349+ parts := strings.SplitN(line, "##", 2)
350+ if len(parts) == 2 {
351+ domain := parts[0]
352+ selector := parts[1]
353+
354+ if domain == "" {
355+ compiled.GenericHiding = append(compiled.GenericHiding, selector)
356+ } else {
357+ if compiled.CosmeticRules == nil {
358+ compiled.CosmeticRules = make(map[string][]string)
359+ }
360+ compiled.CosmeticRules[domain] = append(compiled.CosmeticRules[domain], selector)
361+ }
362+ }
363+ }
364+ // Network filters would be handled here too
365+}
366+
367+// removeRuleFromCompiled marks a rule for removal
368+func (fu *FilterUpdater) removeRuleFromCompiled(compiled *CompiledFilters, rule interface{}) {
369+ // Implementation would mark rules for removal
370+ // For now, we'll just log the removal
371+ ruleMap, ok := rule.(map[string]string)
372+ if ok {
373+ logging.Debug(fmt.Sprintf("Marking rule for removal: %s", ruleMap["line"]))
374+ }
375+}
376+
377+// applyUpdate applies a compiled filter update to the manager
378+func (fu *FilterUpdater) applyUpdate(update *FilterUpdate) error {
379+ if update.Compiled == nil {
380+ return fmt.Errorf("no compiled filters in update")
381+ }
382+
383+ // Apply the update to the filter manager
384+ fu.manager.compileMutex.Lock()
385+ defer fu.manager.compileMutex.Unlock()
386+
387+ // Merge the update with existing filters
388+ if fu.manager.compiled == nil {
389+ fu.manager.compiled = update.Compiled
390+ } else {
391+ fu.manager.compiled.Merge(update.Compiled)
392+ }
393+
394+ // Update cosmetic rules
395+ if err := fu.manager.cosmeticInjector.InjectRules(update.Compiled.CosmeticRules); err != nil {
396+ return fmt.Errorf("failed to inject cosmetic rules: %w", err)
397+ }
398+
399+ // Store the updated content for future diffs
400+ fu.storePreviousContent(update.ListID, update.Current)
401+
402+ logging.Info(fmt.Sprintf("Applied filter update for %s", update.URL))
403+ return nil
404+}
405+
406+// getListID generates a unique ID for a filter list URL
407+func (fu *FilterUpdater) getListID(url string) string {
408+ hash := sha256.Sum256([]byte(url))
409+ return fmt.Sprintf("%x", hash)[:16]
410+}
411+
412+// getPreviousContent retrieves previously stored filter list content
413+func (fu *FilterUpdater) getPreviousContent(listID string) []byte {
414+ // This would typically read from persistent storage
415+ // For now, return nil (no previous content)
416+ return nil
417+}
418+
419+// storePreviousContent stores filter list content for future diff operations
420+func (fu *FilterUpdater) storePreviousContent(listID string, content []byte) {
421+ // This would typically write to persistent storage
422+ // For now, we'll just log the action
423+ logging.Debug(fmt.Sprintf("Storing %d bytes for list %s", len(content), listID))
424+}
425+
426+// contentEqual checks if two byte slices contain the same content
427+func (fu *FilterUpdater) contentEqual(a, b []byte) bool {
428+ if len(a) != len(b) {
429+ return false
430+ }
431+
432+ hashA := sha256.Sum256(a)
433+ hashB := sha256.Sum256(b)
434+
435+ return hashA == hashB
436+}
437+
438+// Diff finds added and removed lines between two versions
439+func (de *DiffEngine) Diff(previous, current []byte) (added []string, removed []string) {
440+ prevLines := strings.Split(string(previous), "\n")
441+ currLines := strings.Split(string(current), "\n")
442+
443+ // Create sets for efficient lookup
444+ prevSet := make(map[string]bool)
445+ currSet := make(map[string]bool)
446+
447+ for _, line := range prevLines {
448+ line = strings.TrimSpace(line)
449+ if line != "" {
450+ prevSet[line] = true
451+ }
452+ }
453+
454+ for _, line := range currLines {
455+ line = strings.TrimSpace(line)
456+ if line != "" {
457+ currSet[line] = true
458+ }
459+ }
460+
461+ // Find added lines (in current but not in previous)
462+ for line := range currSet {
463+ if !prevSet[line] {
464+ added = append(added, line)
465+ }
466+ }
467+
468+ // Find removed lines (in previous but not in current)
469+ for line := range prevSet {
470+ if !currSet[line] {
471+ removed = append(removed, line)
472+ }
473+ }
474+
475+ return added, removed
476+}
M pkg/webkit/build_cgo_stub.go +1 -0
@@ -4,6 +4,7 @@ package webkit
44
55 /*
66 #cgo pkg-config: webkitgtk-6.0 gtk4
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
78 #include <webkit/webkit.h>
89 #include <gtk/gtk.h>
910 */
@@ -4,6 +4,7 @@ package webkit
4 4
5 /*5 /*
6 #cgo pkg-config: webkitgtk-6.0 gtk46 #cgo pkg-config: webkitgtk-6.0 gtk4
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
7 #include <webkit/webkit.h>8 #include <webkit/webkit.h>
8 #include <gtk/gtk.h>9 #include <gtk/gtk.h>
9 */10 */
A pkg/webkit/debug_cgo.go +137 -0
@@ -0,0 +1,137 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: webkitgtk-6.0 gtk4
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8+#include <webkit/webkit.h>
9+#include <stdlib.h>
10+
11+// WebKit debug logging callback
12+extern void webkit_debug_log(char* message);
13+
14+// Forward declaration
15+static void webkit_debug_log_handler(const gchar *log_domain,
16+ GLogLevelFlags log_level,
17+ const gchar *message,
18+ gpointer user_data);
19+
20+static void setup_webkit_debug_logging() {
21+ // Enable WebKit debug logging if WEBKIT_DEBUG environment variable is set
22+ const char* debug_env = g_getenv("WEBKIT_DEBUG");
23+ if (debug_env) {
24+ g_setenv("G_MESSAGES_DEBUG", "WebKit", TRUE);
25+ g_setenv("WEBKIT_DEBUG", debug_env, TRUE);
26+
27+ // Set up custom log handler for better integration
28+ g_log_set_handler("WebKit",
29+ G_LOG_LEVEL_DEBUG | G_LOG_LEVEL_INFO | G_LOG_LEVEL_MESSAGE,
30+ (GLogFunc)webkit_debug_log_handler, NULL);
31+ }
32+}
33+
34+static void webkit_debug_log_handler(const gchar *log_domain,
35+ GLogLevelFlags log_level,
36+ const gchar *message,
37+ gpointer user_data) {
38+ // Format log message and send to Go
39+ char* formatted_msg = g_strdup_printf("[%s] %s", log_domain, message);
40+ webkit_debug_log(formatted_msg);
41+ g_free(formatted_msg);
42+}
43+
44+// Enable detailed content filtering debug logs
45+static void enable_filtering_debug() {
46+ g_setenv("WEBKIT_DEBUG", "Network:preconnectTo,ContentFilters", TRUE);
47+}
48+
49+// Check if WebView is in a valid state for operations
50+static gboolean check_webview_state(WebKitWebView *webview) {
51+ if (!webview) return FALSE;
52+ if (!WEBKIT_IS_WEB_VIEW(webview)) return FALSE;
53+
54+ // Check if WebView is not being destroyed
55+ GObject *obj = G_OBJECT(webview);
56+ if (g_object_get_data(obj, "webkit-destroying")) return FALSE;
57+
58+ return TRUE;
59+}
60+*/
61+import "C"
62+
63+import (
64+ "os"
65+
66+ "github.com/bnema/dumber/internal/config"
67+ "github.com/bnema/dumber/internal/logging"
68+)
69+
70+//export webkit_debug_log
71+func webkit_debug_log(message *C.char) {
72+ msg := C.GoString(message)
73+ logging.Debug("[webkit-internal] " + msg)
74+}
75+
76+// SetupWebKitDebugLogging initializes WebKit debug logging based on config and environment variables
77+func SetupWebKitDebugLogging(cfg *config.Config) {
78+ // Check if debug mode is enabled via config or environment variables
79+ debugEnabled := (cfg != nil && cfg.Debug.EnableWebKitDebug) ||
80+ os.Getenv("WEBKIT_DEBUG") != "" ||
81+ os.Getenv("DUMBER_DEBUG") != "" ||
82+ (cfg != nil && cfg.Debug.EnableGeneralDebug)
83+
84+ if debugEnabled {
85+ logging.Info("[webkit] Enabling WebKit debug logging")
86+
87+ // Set debug categories from config or environment
88+ var debugCategories string
89+ if cfg != nil && cfg.Debug.WebKitDebugCategories != "" {
90+ debugCategories = cfg.Debug.WebKitDebugCategories
91+ } else if envDebug := os.Getenv("WEBKIT_DEBUG"); envDebug != "" {
92+ debugCategories = envDebug
93+ } else {
94+ debugCategories = "Network:preconnectTo,ContentFilters"
95+ }
96+
97+ // Set the environment variable for WebKit
98+ os.Setenv("WEBKIT_DEBUG", debugCategories)
99+
100+ C.setup_webkit_debug_logging()
101+
102+ // Enable filtering-specific debug logs if configured
103+ if cfg != nil && cfg.Debug.EnableFilteringDebug {
104+ C.enable_filtering_debug()
105+ logging.Info("[webkit] Content filtering debug logging enabled")
106+ }
107+
108+ logging.Info("[webkit] WebKit debug logging enabled with categories: " + debugCategories)
109+ }
110+}
111+
112+// CheckWebViewState validates that a WebView is in a safe state for operations
113+func (w *WebView) CheckWebViewState() bool {
114+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
115+ return false
116+ }
117+
118+ return C.check_webview_state(w.native.wv) != 0
119+}
120+
121+// LogDebugInfo logs debug information about the current WebView state
122+func (w *WebView) LogDebugInfo() {
123+ if !w.CheckWebViewState() {
124+ logging.Debug("[webkit] WebView is not in valid state")
125+ return
126+ }
127+
128+ logging.Debug("[webkit] WebView state: valid and ready for operations")
129+
130+ // Log content manager state
131+ manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
132+ if manager != nil {
133+ logging.Debug("[webkit] User content manager: available")
134+ } else {
135+ logging.Debug("[webkit] User content manager: not available")
136+ }
137+}
@@ -0,0 +1,137 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: webkitgtk-6.0 gtk4
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8+#include <webkit/webkit.h>
9+#include <stdlib.h>
10+
11+// WebKit debug logging callback
12+extern void webkit_debug_log(char* message);
13+
14+// Forward declaration
15+static void webkit_debug_log_handler(const gchar *log_domain,
16+ GLogLevelFlags log_level,
17+ const gchar *message,
18+ gpointer user_data);
19+
20+static void setup_webkit_debug_logging() {
21+ // Enable WebKit debug logging if WEBKIT_DEBUG environment variable is set
22+ const char* debug_env = g_getenv("WEBKIT_DEBUG");
23+ if (debug_env) {
24+ g_setenv("G_MESSAGES_DEBUG", "WebKit", TRUE);
25+ g_setenv("WEBKIT_DEBUG", debug_env, TRUE);
26+
27+ // Set up custom log handler for better integration
28+ g_log_set_handler("WebKit",
29+ G_LOG_LEVEL_DEBUG | G_LOG_LEVEL_INFO | G_LOG_LEVEL_MESSAGE,
30+ (GLogFunc)webkit_debug_log_handler, NULL);
31+ }
32+}
33+
34+static void webkit_debug_log_handler(const gchar *log_domain,
35+ GLogLevelFlags log_level,
36+ const gchar *message,
37+ gpointer user_data) {
38+ // Format log message and send to Go
39+ char* formatted_msg = g_strdup_printf("[%s] %s", log_domain, message);
40+ webkit_debug_log(formatted_msg);
41+ g_free(formatted_msg);
42+}
43+
44+// Enable detailed content filtering debug logs
45+static void enable_filtering_debug() {
46+ g_setenv("WEBKIT_DEBUG", "Network:preconnectTo,ContentFilters", TRUE);
47+}
48+
49+// Check if WebView is in a valid state for operations
50+static gboolean check_webview_state(WebKitWebView *webview) {
51+ if (!webview) return FALSE;
52+ if (!WEBKIT_IS_WEB_VIEW(webview)) return FALSE;
53+
54+ // Check if WebView is not being destroyed
55+ GObject *obj = G_OBJECT(webview);
56+ if (g_object_get_data(obj, "webkit-destroying")) return FALSE;
57+
58+ return TRUE;
59+}
60+*/
61+import "C"
62+
63+import (
64+ "os"
65+
66+ "github.com/bnema/dumber/internal/config"
67+ "github.com/bnema/dumber/internal/logging"
68+)
69+
70+//export webkit_debug_log
71+func webkit_debug_log(message *C.char) {
72+ msg := C.GoString(message)
73+ logging.Debug("[webkit-internal] " + msg)
74+}
75+
76+// SetupWebKitDebugLogging initializes WebKit debug logging based on config and environment variables
77+func SetupWebKitDebugLogging(cfg *config.Config) {
78+ // Check if debug mode is enabled via config or environment variables
79+ debugEnabled := (cfg != nil && cfg.Debug.EnableWebKitDebug) ||
80+ os.Getenv("WEBKIT_DEBUG") != "" ||
81+ os.Getenv("DUMBER_DEBUG") != "" ||
82+ (cfg != nil && cfg.Debug.EnableGeneralDebug)
83+
84+ if debugEnabled {
85+ logging.Info("[webkit] Enabling WebKit debug logging")
86+
87+ // Set debug categories from config or environment
88+ var debugCategories string
89+ if cfg != nil && cfg.Debug.WebKitDebugCategories != "" {
90+ debugCategories = cfg.Debug.WebKitDebugCategories
91+ } else if envDebug := os.Getenv("WEBKIT_DEBUG"); envDebug != "" {
92+ debugCategories = envDebug
93+ } else {
94+ debugCategories = "Network:preconnectTo,ContentFilters"
95+ }
96+
97+ // Set the environment variable for WebKit
98+ os.Setenv("WEBKIT_DEBUG", debugCategories)
99+
100+ C.setup_webkit_debug_logging()
101+
102+ // Enable filtering-specific debug logs if configured
103+ if cfg != nil && cfg.Debug.EnableFilteringDebug {
104+ C.enable_filtering_debug()
105+ logging.Info("[webkit] Content filtering debug logging enabled")
106+ }
107+
108+ logging.Info("[webkit] WebKit debug logging enabled with categories: " + debugCategories)
109+ }
110+}
111+
112+// CheckWebViewState validates that a WebView is in a safe state for operations
113+func (w *WebView) CheckWebViewState() bool {
114+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
115+ return false
116+ }
117+
118+ return C.check_webview_state(w.native.wv) != 0
119+}
120+
121+// LogDebugInfo logs debug information about the current WebView state
122+func (w *WebView) LogDebugInfo() {
123+ if !w.CheckWebViewState() {
124+ logging.Debug("[webkit] WebView is not in valid state")
125+ return
126+ }
127+
128+ logging.Debug("[webkit] WebView state: valid and ready for operations")
129+
130+ // Log content manager state
131+ manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
132+ if manager != nil {
133+ logging.Debug("[webkit] User content manager: available")
134+ } else {
135+ logging.Debug("[webkit] User content manager: not available")
136+ }
137+}
A pkg/webkit/debug_stub.go +23 -0
@@ -0,0 +1,23 @@
1+//go:build !webkit_cgo
2+
3+package webkit
4+
5+import (
6+ "github.com/bnema/dumber/internal/config"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// SetupWebKitDebugLogging is a stub for non-CGO builds
11+func SetupWebKitDebugLogging(cfg *config.Config) {
12+ logging.Info("[webkit] Debug logging not available in stub build")
13+}
14+
15+// CheckWebViewState always returns false in stub builds
16+func (w *WebView) CheckWebViewState() bool {
17+ return false
18+}
19+
20+// LogDebugInfo is a stub for non-CGO builds
21+func (w *WebView) LogDebugInfo() {
22+ logging.Debug("[webkit] Debug info not available in stub build")
23+}
@@ -0,0 +1,23 @@
1+//go:build !webkit_cgo
2+
3+package webkit
4+
5+import (
6+ "github.com/bnema/dumber/internal/config"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// SetupWebKitDebugLogging is a stub for non-CGO builds
11+func SetupWebKitDebugLogging(cfg *config.Config) {
12+ logging.Info("[webkit] Debug logging not available in stub build")
13+}
14+
15+// CheckWebViewState always returns false in stub builds
16+func (w *WebView) CheckWebViewState() bool {
17+ return false
18+}
19+
20+// LogDebugInfo is a stub for non-CGO builds
21+func (w *WebView) LogDebugInfo() {
22+ logging.Debug("[webkit] Debug info not available in stub build")
23+}
M pkg/webkit/devtools_cgo.go +1 -0
@@ -4,6 +4,7 @@ package webkit
44
55 /*
66 #cgo pkg-config: webkitgtk-6.0 gtk4
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
78 #include <webkit/webkit.h>
89 */
910 import "C"
@@ -4,6 +4,7 @@ package webkit
4 4
5 /*5 /*
6 #cgo pkg-config: webkitgtk-6.0 gtk46 #cgo pkg-config: webkitgtk-6.0 gtk4
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
7 #include <webkit/webkit.h>8 #include <webkit/webkit.h>
8 */9 */
9 import "C"10 import "C"
M pkg/webkit/errors.go +6 -0
@@ -5,3 +5,9 @@ import "errors"
55
66 // ErrNotImplemented is returned by stub implementations pending WebKit2GTK migration.
77 var ErrNotImplemented = errors.New("webkit: not implemented")
8+
9+// ErrWebViewNotInitialized is returned when WebView operations are attempted on uninitialized WebView
10+var ErrWebViewNotInitialized = errors.New("webkit: webview not initialized")
11+
12+// ErrContentManagerNotFound is returned when WebKit user content manager cannot be retrieved
13+var ErrContentManagerNotFound = errors.New("webkit: user content manager not found")
@@ -5,3 +5,9 @@ import "errors"
5 5
6 // ErrNotImplemented is returned by stub implementations pending WebKit2GTK migration.6 // ErrNotImplemented is returned by stub implementations pending WebKit2GTK migration.
7 var ErrNotImplemented = errors.New("webkit: not implemented")7 var ErrNotImplemented = errors.New("webkit: not implemented")
8+
9+// ErrWebViewNotInitialized is returned when WebView operations are attempted on uninitialized WebView
10+var ErrWebViewNotInitialized = errors.New("webkit: webview not initialized")
11+
12+// ErrContentManagerNotFound is returned when WebKit user content manager cannot be retrieved
13+var ErrContentManagerNotFound = errors.New("webkit: user content manager not found")
A pkg/webkit/filtering_cgo.go +249 -0
@@ -0,0 +1,249 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: webkitgtk-6.0 gtk4
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8+#include <webkit/webkit.h>
9+#include <stdlib.h>
10+
11+// Global filter store instance
12+static WebKitUserContentFilterStore *filter_store = NULL;
13+
14+// External Go functions
15+extern char* get_webkit_filter_store_path();
16+extern void log_filter_success(char* identifier);
17+extern void log_filter_error(char* error_msg, char* identifier);
18+
19+// Callback for save operation completion
20+static void filter_save_callback(GObject *source_object,
21+ GAsyncResult *result,
22+ gpointer user_data) {
23+ WebKitUserContentFilterStore *store = WEBKIT_USER_CONTENT_FILTER_STORE(source_object);
24+ WebKitUserContentManager *manager = WEBKIT_USER_CONTENT_MANAGER(user_data);
25+ GError *error = NULL;
26+
27+ WebKitUserContentFilter *filter = webkit_user_content_filter_store_save_finish(store, result, &error);
28+
29+ if (filter && !error) {
30+ webkit_user_content_manager_add_filter(manager, filter);
31+ const char *id = webkit_user_content_filter_get_identifier(filter);
32+ log_filter_success((char*)id);
33+ webkit_user_content_filter_unref(filter);
34+ }
35+
36+ if (error) {
37+ char *msg = g_strdup(error->message);
38+ log_filter_error(msg, "unknown");
39+ g_free(msg);
40+ g_error_free(error);
41+ }
42+}
43+
44+// Initialize filter store if not already done
45+static void ensure_filter_store() {
46+ if (filter_store == NULL) {
47+ // Get XDG-compliant path from Go (with tmp fallback)
48+ char *store_path = get_webkit_filter_store_path();
49+ filter_store = webkit_user_content_filter_store_new(store_path);
50+ free(store_path); // Free the C string returned by Go
51+ }
52+}
53+
54+static void apply_content_filter(WebKitUserContentManager *manager,
55+ const char *filter_json,
56+ const char *identifier) {
57+ ensure_filter_store();
58+
59+ GBytes *bytes = g_bytes_new(filter_json, strlen(filter_json));
60+
61+ // Save filter to store asynchronously - it will be applied in the callback
62+ webkit_user_content_filter_store_save(filter_store,
63+ identifier,
64+ bytes,
65+ NULL,
66+ filter_save_callback,
67+ manager);
68+
69+ g_bytes_unref(bytes);
70+}
71+
72+static void inject_cosmetic_script(WebKitUserContentManager *manager,
73+ const char *script_source) {
74+ // Use isolated script world to prevent interference with page JavaScript
75+ WebKitUserScript *script = webkit_user_script_new_for_world(
76+ script_source,
77+ WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
78+ WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_END, // Changed from DOCUMENT_START to avoid preconnect interference
79+ "dumber-cosmetic", // Isolated world name
80+ NULL, NULL
81+ );
82+
83+ webkit_user_content_manager_add_script(manager, script);
84+ webkit_user_script_unref(script);
85+}
86+
87+static void clear_all_filters(WebKitUserContentManager *manager) {
88+ webkit_user_content_manager_remove_all_filters(manager);
89+}
90+
91+static void clear_all_scripts(WebKitUserContentManager *manager) {
92+ webkit_user_content_manager_remove_all_scripts(manager);
93+}
94+*/
95+import "C"
96+
97+import (
98+ "encoding/json"
99+ "fmt"
100+ "os"
101+ "path/filepath"
102+ "unsafe"
103+
104+ "github.com/bnema/dumber/internal/config"
105+ "github.com/bnema/dumber/internal/logging"
106+)
107+
108+//export get_webkit_filter_store_path
109+func get_webkit_filter_store_path() *C.char {
110+ // Try XDG directories first
111+ if filterCacheDir, err := config.GetFilterCacheDir(); err == nil {
112+ webkitFilterDir := filepath.Join(filterCacheDir, "webkit-store")
113+ if err := os.MkdirAll(webkitFilterDir, 0755); err == nil {
114+ return C.CString(webkitFilterDir)
115+ }
116+ }
117+
118+ // Fallback to tmp if XDG fails
119+ tmpDir := os.TempDir()
120+ fallbackDir := filepath.Join(tmpDir, "dumber-filters")
121+ if err := os.MkdirAll(fallbackDir, 0755); err == nil {
122+ logging.Warn("[webkit] Using fallback tmp directory for filter store: " + fallbackDir)
123+ return C.CString(fallbackDir)
124+ }
125+
126+ // Last resort - return tmp dir
127+ logging.Error("[webkit] Failed to create filter store directory, using tmp")
128+ return C.CString(tmpDir)
129+}
130+
131+//export log_filter_success
132+func log_filter_success(identifier *C.char) {
133+ id := C.GoString(identifier)
134+ logging.Info("[webkit] Network filter applied successfully: " + id)
135+}
136+
137+//export log_filter_error
138+func log_filter_error(errorMsg *C.char, identifier *C.char) {
139+ msg := C.GoString(errorMsg)
140+ id := C.GoString(identifier)
141+ logging.Error("[webkit] Failed to apply network filter '" + id + "': " + msg)
142+}
143+
144+// ApplyContentFilters applies WebKit native content filters with error recovery
145+func (w *WebView) ApplyContentFilters(filters []byte, identifier string) error {
146+ // Check WebView state with debug support
147+ if !w.CheckWebViewState() {
148+ w.LogDebugInfo()
149+ return ErrWebViewNotInitialized
150+ }
151+
152+ // Validate input
153+ if len(filters) == 0 || identifier == "" {
154+ return fmt.Errorf("invalid filter parameters: empty filters or identifier")
155+ }
156+
157+ // Check if filters are valid JSON
158+ if !json.Valid(filters) {
159+ return fmt.Errorf("invalid filter JSON for identifier: %s", identifier)
160+ }
161+
162+ defer func() {
163+ if r := recover(); r != nil {
164+ logging.Error(fmt.Sprintf("[webkit] Panic in ApplyContentFilters: %v", r))
165+ }
166+ }()
167+
168+ manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
169+ if manager == nil {
170+ return ErrContentManagerNotFound
171+ }
172+
173+ cFilters := C.CString(string(filters))
174+ cID := C.CString(identifier)
175+ defer C.free(unsafe.Pointer(cFilters))
176+ defer C.free(unsafe.Pointer(cID))
177+
178+ logging.Info("[webkit] Applying network filters: " + identifier + " (" + fmt.Sprintf("%d", len(filters)) + " bytes)")
179+
180+ // Apply filter with error checking
181+ C.apply_content_filter(manager, cFilters, cID)
182+ return nil
183+}
184+
185+// InjectCosmeticFilter injects JavaScript for cosmetic filtering with error recovery
186+func (w *WebView) InjectCosmeticFilter(script string) error {
187+ // Check WebView state with debug support
188+ if !w.CheckWebViewState() {
189+ w.LogDebugInfo()
190+ return ErrWebViewNotInitialized
191+ }
192+
193+ // Validate input
194+ if len(script) == 0 {
195+ return fmt.Errorf("empty cosmetic filter script")
196+ }
197+
198+ defer func() {
199+ if r := recover(); r != nil {
200+ logging.Error(fmt.Sprintf("[webkit] Panic in InjectCosmeticFilter: %v", r))
201+ }
202+ }()
203+
204+ manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
205+ if manager == nil {
206+ return ErrContentManagerNotFound
207+ }
208+
209+ cScript := C.CString(script)
210+ defer C.free(unsafe.Pointer(cScript))
211+
212+ logging.Info("[webkit] Injecting cosmetic filter script (" + fmt.Sprintf("%d", len(script)) + " chars)")
213+
214+ // Inject script with error recovery
215+ C.inject_cosmetic_script(manager, cScript)
216+ return nil
217+}
218+
219+// ClearAllFilters removes all content filters
220+func (w *WebView) ClearAllFilters() error {
221+ if w.native == nil || w.native.wv == nil {
222+ return ErrWebViewNotInitialized
223+ }
224+
225+ manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
226+ if manager == nil {
227+ return ErrContentManagerNotFound
228+ }
229+
230+ logging.Info("[webkit] Clearing all content filters")
231+ C.clear_all_filters(manager)
232+ return nil
233+}
234+
235+// ClearAllScripts removes all injected scripts
236+func (w *WebView) ClearAllScripts() error {
237+ if w.native == nil || w.native.wv == nil {
238+ return ErrWebViewNotInitialized
239+ }
240+
241+ manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
242+ if manager == nil {
243+ return ErrContentManagerNotFound
244+ }
245+
246+ logging.Info("[webkit] Clearing all injected scripts")
247+ C.clear_all_scripts(manager)
248+ return nil
249+}
@@ -0,0 +1,249 @@
1+//go:build webkit_cgo
2+
3+package webkit
4+
5+/*
6+#cgo pkg-config: webkitgtk-6.0 gtk4
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8+#include <webkit/webkit.h>
9+#include <stdlib.h>
10+
11+// Global filter store instance
12+static WebKitUserContentFilterStore *filter_store = NULL;
13+
14+// External Go functions
15+extern char* get_webkit_filter_store_path();
16+extern void log_filter_success(char* identifier);
17+extern void log_filter_error(char* error_msg, char* identifier);
18+
19+// Callback for save operation completion
20+static void filter_save_callback(GObject *source_object,
21+ GAsyncResult *result,
22+ gpointer user_data) {
23+ WebKitUserContentFilterStore *store = WEBKIT_USER_CONTENT_FILTER_STORE(source_object);
24+ WebKitUserContentManager *manager = WEBKIT_USER_CONTENT_MANAGER(user_data);
25+ GError *error = NULL;
26+
27+ WebKitUserContentFilter *filter = webkit_user_content_filter_store_save_finish(store, result, &error);
28+
29+ if (filter && !error) {
30+ webkit_user_content_manager_add_filter(manager, filter);
31+ const char *id = webkit_user_content_filter_get_identifier(filter);
32+ log_filter_success((char*)id);
33+ webkit_user_content_filter_unref(filter);
34+ }
35+
36+ if (error) {
37+ char *msg = g_strdup(error->message);
38+ log_filter_error(msg, "unknown");
39+ g_free(msg);
40+ g_error_free(error);
41+ }
42+}
43+
44+// Initialize filter store if not already done
45+static void ensure_filter_store() {
46+ if (filter_store == NULL) {
47+ // Get XDG-compliant path from Go (with tmp fallback)
48+ char *store_path = get_webkit_filter_store_path();
49+ filter_store = webkit_user_content_filter_store_new(store_path);
50+ free(store_path); // Free the C string returned by Go
51+ }
52+}
53+
54+static void apply_content_filter(WebKitUserContentManager *manager,
55+ const char *filter_json,
56+ const char *identifier) {
57+ ensure_filter_store();
58+
59+ GBytes *bytes = g_bytes_new(filter_json, strlen(filter_json));
60+
61+ // Save filter to store asynchronously - it will be applied in the callback
62+ webkit_user_content_filter_store_save(filter_store,
63+ identifier,
64+ bytes,
65+ NULL,
66+ filter_save_callback,
67+ manager);
68+
69+ g_bytes_unref(bytes);
70+}
71+
72+static void inject_cosmetic_script(WebKitUserContentManager *manager,
73+ const char *script_source) {
74+ // Use isolated script world to prevent interference with page JavaScript
75+ WebKitUserScript *script = webkit_user_script_new_for_world(
76+ script_source,
77+ WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
78+ WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_END, // Changed from DOCUMENT_START to avoid preconnect interference
79+ "dumber-cosmetic", // Isolated world name
80+ NULL, NULL
81+ );
82+
83+ webkit_user_content_manager_add_script(manager, script);
84+ webkit_user_script_unref(script);
85+}
86+
87+static void clear_all_filters(WebKitUserContentManager *manager) {
88+ webkit_user_content_manager_remove_all_filters(manager);
89+}
90+
91+static void clear_all_scripts(WebKitUserContentManager *manager) {
92+ webkit_user_content_manager_remove_all_scripts(manager);
93+}
94+*/
95+import "C"
96+
97+import (
98+ "encoding/json"
99+ "fmt"
100+ "os"
101+ "path/filepath"
102+ "unsafe"
103+
104+ "github.com/bnema/dumber/internal/config"
105+ "github.com/bnema/dumber/internal/logging"
106+)
107+
108+//export get_webkit_filter_store_path
109+func get_webkit_filter_store_path() *C.char {
110+ // Try XDG directories first
111+ if filterCacheDir, err := config.GetFilterCacheDir(); err == nil {
112+ webkitFilterDir := filepath.Join(filterCacheDir, "webkit-store")
113+ if err := os.MkdirAll(webkitFilterDir, 0755); err == nil {
114+ return C.CString(webkitFilterDir)
115+ }
116+ }
117+
118+ // Fallback to tmp if XDG fails
119+ tmpDir := os.TempDir()
120+ fallbackDir := filepath.Join(tmpDir, "dumber-filters")
121+ if err := os.MkdirAll(fallbackDir, 0755); err == nil {
122+ logging.Warn("[webkit] Using fallback tmp directory for filter store: " + fallbackDir)
123+ return C.CString(fallbackDir)
124+ }
125+
126+ // Last resort - return tmp dir
127+ logging.Error("[webkit] Failed to create filter store directory, using tmp")
128+ return C.CString(tmpDir)
129+}
130+
131+//export log_filter_success
132+func log_filter_success(identifier *C.char) {
133+ id := C.GoString(identifier)
134+ logging.Info("[webkit] Network filter applied successfully: " + id)
135+}
136+
137+//export log_filter_error
138+func log_filter_error(errorMsg *C.char, identifier *C.char) {
139+ msg := C.GoString(errorMsg)
140+ id := C.GoString(identifier)
141+ logging.Error("[webkit] Failed to apply network filter '" + id + "': " + msg)
142+}
143+
144+// ApplyContentFilters applies WebKit native content filters with error recovery
145+func (w *WebView) ApplyContentFilters(filters []byte, identifier string) error {
146+ // Check WebView state with debug support
147+ if !w.CheckWebViewState() {
148+ w.LogDebugInfo()
149+ return ErrWebViewNotInitialized
150+ }
151+
152+ // Validate input
153+ if len(filters) == 0 || identifier == "" {
154+ return fmt.Errorf("invalid filter parameters: empty filters or identifier")
155+ }
156+
157+ // Check if filters are valid JSON
158+ if !json.Valid(filters) {
159+ return fmt.Errorf("invalid filter JSON for identifier: %s", identifier)
160+ }
161+
162+ defer func() {
163+ if r := recover(); r != nil {
164+ logging.Error(fmt.Sprintf("[webkit] Panic in ApplyContentFilters: %v", r))
165+ }
166+ }()
167+
168+ manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
169+ if manager == nil {
170+ return ErrContentManagerNotFound
171+ }
172+
173+ cFilters := C.CString(string(filters))
174+ cID := C.CString(identifier)
175+ defer C.free(unsafe.Pointer(cFilters))
176+ defer C.free(unsafe.Pointer(cID))
177+
178+ logging.Info("[webkit] Applying network filters: " + identifier + " (" + fmt.Sprintf("%d", len(filters)) + " bytes)")
179+
180+ // Apply filter with error checking
181+ C.apply_content_filter(manager, cFilters, cID)
182+ return nil
183+}
184+
185+// InjectCosmeticFilter injects JavaScript for cosmetic filtering with error recovery
186+func (w *WebView) InjectCosmeticFilter(script string) error {
187+ // Check WebView state with debug support
188+ if !w.CheckWebViewState() {
189+ w.LogDebugInfo()
190+ return ErrWebViewNotInitialized
191+ }
192+
193+ // Validate input
194+ if len(script) == 0 {
195+ return fmt.Errorf("empty cosmetic filter script")
196+ }
197+
198+ defer func() {
199+ if r := recover(); r != nil {
200+ logging.Error(fmt.Sprintf("[webkit] Panic in InjectCosmeticFilter: %v", r))
201+ }
202+ }()
203+
204+ manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
205+ if manager == nil {
206+ return ErrContentManagerNotFound
207+ }
208+
209+ cScript := C.CString(script)
210+ defer C.free(unsafe.Pointer(cScript))
211+
212+ logging.Info("[webkit] Injecting cosmetic filter script (" + fmt.Sprintf("%d", len(script)) + " chars)")
213+
214+ // Inject script with error recovery
215+ C.inject_cosmetic_script(manager, cScript)
216+ return nil
217+}
218+
219+// ClearAllFilters removes all content filters
220+func (w *WebView) ClearAllFilters() error {
221+ if w.native == nil || w.native.wv == nil {
222+ return ErrWebViewNotInitialized
223+ }
224+
225+ manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
226+ if manager == nil {
227+ return ErrContentManagerNotFound
228+ }
229+
230+ logging.Info("[webkit] Clearing all content filters")
231+ C.clear_all_filters(manager)
232+ return nil
233+}
234+
235+// ClearAllScripts removes all injected scripts
236+func (w *WebView) ClearAllScripts() error {
237+ if w.native == nil || w.native.wv == nil {
238+ return ErrWebViewNotInitialized
239+ }
240+
241+ manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
242+ if manager == nil {
243+ return ErrContentManagerNotFound
244+ }
245+
246+ logging.Info("[webkit] Clearing all injected scripts")
247+ C.clear_all_scripts(manager)
248+ return nil
249+}
M pkg/webkit/script_cgo.go +1 -0
@@ -4,6 +4,7 @@ package webkit
44
55 /*
66 #cgo pkg-config: webkitgtk-6.0 gtk4
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
78 #include <stdlib.h>
89 #include <webkit/webkit.h>
910 */
@@ -4,6 +4,7 @@ package webkit
4 4
5 /*5 /*
6 #cgo pkg-config: webkitgtk-6.0 gtk46 #cgo pkg-config: webkitgtk-6.0 gtk4
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
7 #include <stdlib.h>8 #include <stdlib.h>
8 #include <webkit/webkit.h>9 #include <webkit/webkit.h>
9 */10 */
M pkg/webkit/webview_cgo.go +182 -13
@@ -4,6 +4,7 @@ package webkit
44
55 /*
66 #cgo pkg-config: webkitgtk-6.0 gtk4 javascriptcoregtk-6.0
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
78 #include <stdlib.h>
89 #include <string.h>
910 #include <gtk/gtk.h>
@@ -547,7 +548,7 @@ static void on_alert_dialog_choose_done(GObject *source, GAsyncResult *result, g
547548 }
548549 data->completed = TRUE;
549550 const char *response_names[] = {"GO_BACK", "PROCEED_ONCE", "ALWAYS_ACCEPT"};
550- const char *response_name = (data->response >= 0 && data->response <= 2) ?
551+ const char *response_name = (data->response >= 0 && data->response <= 2) ?
551552 response_names[data->response] : "UNKNOWN";
552553 printf("[dumber] TLS dialog response: %s (button_index=%d)\n", response_name, data->response);
553554 fflush(stdout);
@@ -564,31 +565,31 @@ static gboolean show_tls_warning_dialog_sync(GtkWindow *parent, const char* host
564565
565566 // Create the modern AlertDialog
566567 GtkAlertDialog *dialog = gtk_alert_dialog_new("Certificate Error for %s", hostname);
567-
568+
568569 // Set modal behavior and error details
569570 gtk_alert_dialog_set_modal(dialog, TRUE);
570571 gtk_alert_dialog_set_detail(dialog, error_msg);
571-
572+
572573 // Configure buttons (Go Back = 0, Proceed Once = 1, Always Accept = 2)
573574 const char *buttons[] = {"Go Back", "Proceed Once (Unsafe)", "Always Accept This Site", NULL};
574575 gtk_alert_dialog_set_buttons(dialog, buttons);
575576 gtk_alert_dialog_set_default_button(dialog, 0);
576577 gtk_alert_dialog_set_cancel_button(dialog, 0);
577-
578+
578579 // Set up response data with main loop for sync behavior
579580 AlertDialogResponse response_data = {0, FALSE, g_main_loop_new(NULL, FALSE)};
580-
581+
581582 // Start async operation
582- gtk_alert_dialog_choose(dialog, parent, NULL,
583+ gtk_alert_dialog_choose(dialog, parent, NULL,
583584 on_alert_dialog_choose_done, &response_data);
584-
585+
585586 // Run event loop until dialog completes
586587 g_main_loop_run(response_data.loop);
587588 g_main_loop_unref(response_data.loop);
588-
589+
589590 // Clean up
590591 g_object_unref(dialog);
591-
592+
592593 // Return response code: 0=Go Back, 1=Proceed Once, 2=Always Accept
593594 return response_data.response;
594595 }
@@ -605,14 +606,23 @@ import (
605606 "os"
606607 "path/filepath"
607608 "runtime"
609+ "strings"
608610 "time"
609611 "unsafe"
610612
611613 "github.com/bnema/dumber/internal/config"
612614 "github.com/bnema/dumber/internal/db"
615+ "github.com/bnema/dumber/internal/logging"
613616 _ "github.com/ncruces/go-sqlite3"
614617 )
615618
619+// FilterManager interface for content blocking integration
620+type FilterManager interface {
621+ GetNetworkFilters() ([]byte, error)
622+ GetCosmeticScript() string
623+ GetCosmeticScriptForDomain(domain string) string
624+}
625+
616626 // Helper function to convert bool to int for C interop
617627 func boolToInt(b bool) int {
618628 if b {
@@ -1481,12 +1491,12 @@ func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, cer
14811491 )
14821492
14831493 responseCode := int(result)
1484-
1494+
14851495 switch responseCode {
14861496 case 0: // Go Back
14871497 log.Printf("[tls] User chose to GO BACK for %s", hostname)
14881498 return false
1489-
1499+
14901500 case 1: // Proceed Once
14911501 log.Printf("[tls] User chose to PROCEED ONCE for %s", hostname)
14921502 // Store temporary decision (expires in 24 hours)
@@ -1494,7 +1504,7 @@ func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, cer
14941504 log.Printf("[tls] Warning: Failed to store temporary certificate decision: %v", err)
14951505 }
14961506 return true
1497-
1507+
14981508 case 2: // Always Accept
14991509 log.Printf("[tls] User chose to ALWAYS ACCEPT for %s", hostname)
15001510 // Store permanent decision
@@ -1502,7 +1512,7 @@ func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, cer
15021512 log.Printf("[tls] Warning: Failed to store permanent certificate decision: %v", err)
15031513 }
15041514 return true
1505-
1515+
15061516 default:
15071517 log.Printf("[tls] Unknown response code %d for %s, defaulting to reject", responseCode, hostname)
15081518 return false
@@ -1543,3 +1553,162 @@ func formatTLSErrorMessage(hostname, uri string, errorFlags int, certificateInfo
15431553
15441554 return msg
15451555 }
1556+
1557+// InitializeContentBlocking initializes WebKit content blocking with filter manager
1558+func (w *WebView) InitializeContentBlocking(filterManager FilterManager) error {
1559+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
1560+ return ErrWebViewNotInitialized
1561+ }
1562+
1563+ // Load filters asynchronously with proper error recovery
1564+ go func() {
1565+ defer func() {
1566+ if r := recover(); r != nil {
1567+ logging.Error(fmt.Sprintf("[webkit] Content blocking initialization panic: %v", r))
1568+ }
1569+ }()
1570+
1571+ // Wait longer for WebView to be ready before applying filters on first load
1572+ // This prevents interference with WebKit's preconnect operations
1573+ time.Sleep(1500 * time.Millisecond)
1574+
1575+ // Retry mechanism with exponential backoff
1576+ maxRetries := 5
1577+ baseDelay := 200 * time.Millisecond
1578+
1579+ // Apply network filters with error recovery
1580+ for i := 0; i < maxRetries; i++ {
1581+ if w.destroyed {
1582+ logging.Debug("[webkit] WebView destroyed, aborting filter loading")
1583+ return
1584+ }
1585+
1586+ if filters, err := filterManager.GetNetworkFilters(); err == nil {
1587+ if len(filters) > 10 && string(filters) != "null" {
1588+ logging.Info("[webkit] Loading network filters from filter manager (" + fmt.Sprintf("%d", len(filters)) + " bytes)")
1589+ if err := w.ApplyContentFilters(filters, "network-filters"); err != nil {
1590+ logging.Error("Failed to apply network filters (retry " + fmt.Sprintf("%d", i+1) + "): " + err.Error())
1591+ if i == maxRetries-1 {
1592+ logging.Warn("[webkit] Network filtering disabled due to errors")
1593+ break
1594+ }
1595+ time.Sleep(baseDelay * time.Duration(1<<uint(i))) // Exponential backoff
1596+ continue
1597+ }
1598+ break
1599+ }
1600+ } else if i == maxRetries-1 {
1601+ logging.Error("Failed to get network filters after retries: " + err.Error())
1602+ }
1603+ }
1604+
1605+ // Apply cosmetic filtering with error recovery
1606+ time.Sleep(200 * time.Millisecond) // Additional delay to ensure network setup is complete
1607+
1608+ for i := 0; i < maxRetries; i++ {
1609+ if w.destroyed {
1610+ logging.Debug("[webkit] WebView destroyed, aborting cosmetic filter loading")
1611+ return
1612+ }
1613+
1614+ script := filterManager.GetCosmeticScript()
1615+ if len(script) > 0 {
1616+ logging.Info("[webkit] Loading cosmetic script from filter manager (" + fmt.Sprintf("%d", len(script)) + " chars)")
1617+ if err := w.InjectCosmeticFilter(script); err != nil {
1618+ logging.Error("Failed to inject cosmetic filter (retry " + fmt.Sprintf("%d", i+1) + "): " + err.Error())
1619+ if i == maxRetries-1 {
1620+ logging.Warn("[webkit] Cosmetic filtering disabled due to errors")
1621+ } else {
1622+ time.Sleep(baseDelay * time.Duration(1<<uint(i)))
1623+ continue
1624+ }
1625+ }
1626+ break
1627+ }
1628+ }
1629+
1630+ logging.Info("Content blocking initialization completed")
1631+ }()
1632+
1633+ return nil
1634+}
1635+
1636+// OnNavigate sets up domain-specific cosmetic filtering on navigation
1637+func (w *WebView) OnNavigate(url string, filterManager FilterManager) {
1638+ if w == nil || w.destroyed {
1639+ return
1640+ }
1641+
1642+ domain := extractDomain(url)
1643+ if domain == "" {
1644+ return
1645+ }
1646+
1647+ // Get domain-specific cosmetic rules
1648+ script := filterManager.GetCosmeticScriptForDomain(domain)
1649+ if script != "" {
1650+ // Inject domain-specific cosmetic rules
1651+ logging.Info("[webkit] Applying domain-specific cosmetic rules for: " + domain + " (" + fmt.Sprintf("%d", len(script)) + " chars)")
1652+ w.InjectScript(fmt.Sprintf(
1653+ "if (typeof window.__dumber_cosmetic_init === 'function') { %s }",
1654+ script,
1655+ ))
1656+ } else {
1657+ logging.Debug("[webkit] No domain-specific cosmetic rules found for: " + domain)
1658+ }
1659+}
1660+
1661+// UpdateContentFilters updates the content filters dynamically
1662+func (w *WebView) UpdateContentFilters(filterManager FilterManager) error {
1663+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
1664+ return ErrWebViewNotInitialized
1665+ }
1666+
1667+ // Clear existing filters first
1668+ if err := w.ClearAllFilters(); err != nil {
1669+ return fmt.Errorf("failed to clear existing filters: %w", err)
1670+ }
1671+
1672+ // Apply new filters
1673+ if filters, err := filterManager.GetNetworkFilters(); err == nil {
1674+ logging.Info("[webkit] Updating network filters (" + fmt.Sprintf("%d", len(filters)) + " bytes)")
1675+ if err := w.ApplyContentFilters(filters, "network-filters-updated"); err != nil {
1676+ return fmt.Errorf("failed to apply updated network filters: %w", err)
1677+ }
1678+ }
1679+
1680+ // Update cosmetic filtering
1681+ script := filterManager.GetCosmeticScript()
1682+ logging.Info("[webkit] Updating cosmetic script (" + fmt.Sprintf("%d", len(script)) + " chars)")
1683+ if err := w.InjectCosmeticFilter(script); err != nil {
1684+ return fmt.Errorf("failed to inject updated cosmetic filter: %w", err)
1685+ }
1686+
1687+ logging.Info("Content filters updated successfully")
1688+ return nil
1689+}
1690+
1691+// extractDomain extracts the domain from a URL
1692+func extractDomain(url string) string {
1693+ if url == "" {
1694+ return ""
1695+ }
1696+
1697+ // Simple domain extraction - remove protocol and path
1698+ start := strings.Index(url, "://")
1699+ if start != -1 {
1700+ url = url[start+3:]
1701+ }
1702+
1703+ end := strings.Index(url, "/")
1704+ if end != -1 {
1705+ url = url[:end]
1706+ }
1707+
1708+ // Remove port if present
1709+ if portIdx := strings.Index(url, ":"); portIdx != -1 {
1710+ url = url[:portIdx]
1711+ }
1712+
1713+ return url
1714+}
@@ -4,6 +4,7 @@ package webkit
4 4
5 /*5 /*
6 #cgo pkg-config: webkitgtk-6.0 gtk4 javascriptcoregtk-6.06 #cgo pkg-config: webkitgtk-6.0 gtk4 javascriptcoregtk-6.0
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
7 #include <stdlib.h>8 #include <stdlib.h>
8 #include <string.h>9 #include <string.h>
9 #include <gtk/gtk.h>10 #include <gtk/gtk.h>
@@ -547,7 +548,7 @@ static void on_alert_dialog_choose_done(GObject *source, GAsyncResult *result, g
547 }548 }
548 data->completed = TRUE;549 data->completed = TRUE;
549 const char *response_names[] = {"GO_BACK", "PROCEED_ONCE", "ALWAYS_ACCEPT"};550 const char *response_names[] = {"GO_BACK", "PROCEED_ONCE", "ALWAYS_ACCEPT"};
550- const char *response_name = (data->response >= 0 && data->response <= 2) ? 551+ const char *response_name = (data->response >= 0 && data->response <= 2) ?
551 response_names[data->response] : "UNKNOWN";552 response_names[data->response] : "UNKNOWN";
552 printf("[dumber] TLS dialog response: %s (button_index=%d)\n", response_name, data->response);553 printf("[dumber] TLS dialog response: %s (button_index=%d)\n", response_name, data->response);
553 fflush(stdout);554 fflush(stdout);
@@ -564,31 +565,31 @@ static gboolean show_tls_warning_dialog_sync(GtkWindow *parent, const char* host
564 565
565 // Create the modern AlertDialog566 // Create the modern AlertDialog
566 GtkAlertDialog *dialog = gtk_alert_dialog_new("Certificate Error for %s", hostname);567 GtkAlertDialog *dialog = gtk_alert_dialog_new("Certificate Error for %s", hostname);
567- 568+
568 // Set modal behavior and error details569 // Set modal behavior and error details
569 gtk_alert_dialog_set_modal(dialog, TRUE);570 gtk_alert_dialog_set_modal(dialog, TRUE);
570 gtk_alert_dialog_set_detail(dialog, error_msg);571 gtk_alert_dialog_set_detail(dialog, error_msg);
571- 572+
572 // Configure buttons (Go Back = 0, Proceed Once = 1, Always Accept = 2)573 // Configure buttons (Go Back = 0, Proceed Once = 1, Always Accept = 2)
573 const char *buttons[] = {"Go Back", "Proceed Once (Unsafe)", "Always Accept This Site", NULL};574 const char *buttons[] = {"Go Back", "Proceed Once (Unsafe)", "Always Accept This Site", NULL};
574 gtk_alert_dialog_set_buttons(dialog, buttons);575 gtk_alert_dialog_set_buttons(dialog, buttons);
575 gtk_alert_dialog_set_default_button(dialog, 0);576 gtk_alert_dialog_set_default_button(dialog, 0);
576 gtk_alert_dialog_set_cancel_button(dialog, 0);577 gtk_alert_dialog_set_cancel_button(dialog, 0);
577- 578+
578 // Set up response data with main loop for sync behavior579 // Set up response data with main loop for sync behavior
579 AlertDialogResponse response_data = {0, FALSE, g_main_loop_new(NULL, FALSE)};580 AlertDialogResponse response_data = {0, FALSE, g_main_loop_new(NULL, FALSE)};
580- 581+
581 // Start async operation582 // Start async operation
582- gtk_alert_dialog_choose(dialog, parent, NULL, 583+ gtk_alert_dialog_choose(dialog, parent, NULL,
583 on_alert_dialog_choose_done, &response_data);584 on_alert_dialog_choose_done, &response_data);
584- 585+
585 // Run event loop until dialog completes586 // Run event loop until dialog completes
586 g_main_loop_run(response_data.loop);587 g_main_loop_run(response_data.loop);
587 g_main_loop_unref(response_data.loop);588 g_main_loop_unref(response_data.loop);
588- 589+
589 // Clean up590 // Clean up
590 g_object_unref(dialog);591 g_object_unref(dialog);
591- 592+
592 // Return response code: 0=Go Back, 1=Proceed Once, 2=Always Accept593 // Return response code: 0=Go Back, 1=Proceed Once, 2=Always Accept
593 return response_data.response;594 return response_data.response;
594 }595 }
@@ -605,14 +606,23 @@ import (
605 "os"606 "os"
606 "path/filepath"607 "path/filepath"
607 "runtime"608 "runtime"
609+ "strings"
608 "time"610 "time"
609 "unsafe"611 "unsafe"
610 612
611 "github.com/bnema/dumber/internal/config"613 "github.com/bnema/dumber/internal/config"
612 "github.com/bnema/dumber/internal/db"614 "github.com/bnema/dumber/internal/db"
615+ "github.com/bnema/dumber/internal/logging"
613 _ "github.com/ncruces/go-sqlite3"616 _ "github.com/ncruces/go-sqlite3"
614 )617 )
615 618
619+// FilterManager interface for content blocking integration
620+type FilterManager interface {
621+ GetNetworkFilters() ([]byte, error)
622+ GetCosmeticScript() string
623+ GetCosmeticScriptForDomain(domain string) string
624+}
625+
616 // Helper function to convert bool to int for C interop626 // Helper function to convert bool to int for C interop
617 func boolToInt(b bool) int {627 func boolToInt(b bool) int {
618 if b {628 if b {
@@ -1481,12 +1491,12 @@ func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, cer
1481 )1491 )
1482 1492
1483 responseCode := int(result)1493 responseCode := int(result)
1484- 1494+
1485 switch responseCode {1495 switch responseCode {
1486 case 0: // Go Back1496 case 0: // Go Back
1487 log.Printf("[tls] User chose to GO BACK for %s", hostname)1497 log.Printf("[tls] User chose to GO BACK for %s", hostname)
1488 return false1498 return false
1489- 1499+
1490 case 1: // Proceed Once1500 case 1: // Proceed Once
1491 log.Printf("[tls] User chose to PROCEED ONCE for %s", hostname)1501 log.Printf("[tls] User chose to PROCEED ONCE for %s", hostname)
1492 // Store temporary decision (expires in 24 hours)1502 // Store temporary decision (expires in 24 hours)
@@ -1494,7 +1504,7 @@ func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, cer
1494 log.Printf("[tls] Warning: Failed to store temporary certificate decision: %v", err)1504 log.Printf("[tls] Warning: Failed to store temporary certificate decision: %v", err)
1495 }1505 }
1496 return true1506 return true
1497- 1507+
1498 case 2: // Always Accept1508 case 2: // Always Accept
1499 log.Printf("[tls] User chose to ALWAYS ACCEPT for %s", hostname)1509 log.Printf("[tls] User chose to ALWAYS ACCEPT for %s", hostname)
1500 // Store permanent decision1510 // Store permanent decision
@@ -1502,7 +1512,7 @@ func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, cer
1502 log.Printf("[tls] Warning: Failed to store permanent certificate decision: %v", err)1512 log.Printf("[tls] Warning: Failed to store permanent certificate decision: %v", err)
1503 }1513 }
1504 return true1514 return true
1505- 1515+
1506 default:1516 default:
1507 log.Printf("[tls] Unknown response code %d for %s, defaulting to reject", responseCode, hostname)1517 log.Printf("[tls] Unknown response code %d for %s, defaulting to reject", responseCode, hostname)
1508 return false1518 return false
@@ -1543,3 +1553,162 @@ func formatTLSErrorMessage(hostname, uri string, errorFlags int, certificateInfo
1543 1553
1544 return msg1554 return msg
1545 }1555 }
1556+
1557+// InitializeContentBlocking initializes WebKit content blocking with filter manager
1558+func (w *WebView) InitializeContentBlocking(filterManager FilterManager) error {
1559+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
1560+ return ErrWebViewNotInitialized
1561+ }
1562+
1563+ // Load filters asynchronously with proper error recovery
1564+ go func() {
1565+ defer func() {
1566+ if r := recover(); r != nil {
1567+ logging.Error(fmt.Sprintf("[webkit] Content blocking initialization panic: %v", r))
1568+ }
1569+ }()
1570+
1571+ // Wait longer for WebView to be ready before applying filters on first load
1572+ // This prevents interference with WebKit's preconnect operations
1573+ time.Sleep(1500 * time.Millisecond)
1574+
1575+ // Retry mechanism with exponential backoff
1576+ maxRetries := 5
1577+ baseDelay := 200 * time.Millisecond
1578+
1579+ // Apply network filters with error recovery
1580+ for i := 0; i < maxRetries; i++ {
1581+ if w.destroyed {
1582+ logging.Debug("[webkit] WebView destroyed, aborting filter loading")
1583+ return
1584+ }
1585+
1586+ if filters, err := filterManager.GetNetworkFilters(); err == nil {
1587+ if len(filters) > 10 && string(filters) != "null" {
1588+ logging.Info("[webkit] Loading network filters from filter manager (" + fmt.Sprintf("%d", len(filters)) + " bytes)")
1589+ if err := w.ApplyContentFilters(filters, "network-filters"); err != nil {
1590+ logging.Error("Failed to apply network filters (retry " + fmt.Sprintf("%d", i+1) + "): " + err.Error())
1591+ if i == maxRetries-1 {
1592+ logging.Warn("[webkit] Network filtering disabled due to errors")
1593+ break
1594+ }
1595+ time.Sleep(baseDelay * time.Duration(1<<uint(i))) // Exponential backoff
1596+ continue
1597+ }
1598+ break
1599+ }
1600+ } else if i == maxRetries-1 {
1601+ logging.Error("Failed to get network filters after retries: " + err.Error())
1602+ }
1603+ }
1604+
1605+ // Apply cosmetic filtering with error recovery
1606+ time.Sleep(200 * time.Millisecond) // Additional delay to ensure network setup is complete
1607+
1608+ for i := 0; i < maxRetries; i++ {
1609+ if w.destroyed {
1610+ logging.Debug("[webkit] WebView destroyed, aborting cosmetic filter loading")
1611+ return
1612+ }
1613+
1614+ script := filterManager.GetCosmeticScript()
1615+ if len(script) > 0 {
1616+ logging.Info("[webkit] Loading cosmetic script from filter manager (" + fmt.Sprintf("%d", len(script)) + " chars)")
1617+ if err := w.InjectCosmeticFilter(script); err != nil {
1618+ logging.Error("Failed to inject cosmetic filter (retry " + fmt.Sprintf("%d", i+1) + "): " + err.Error())
1619+ if i == maxRetries-1 {
1620+ logging.Warn("[webkit] Cosmetic filtering disabled due to errors")
1621+ } else {
1622+ time.Sleep(baseDelay * time.Duration(1<<uint(i)))
1623+ continue
1624+ }
1625+ }
1626+ break
1627+ }
1628+ }
1629+
1630+ logging.Info("Content blocking initialization completed")
1631+ }()
1632+
1633+ return nil
1634+}
1635+
1636+// OnNavigate sets up domain-specific cosmetic filtering on navigation
1637+func (w *WebView) OnNavigate(url string, filterManager FilterManager) {
1638+ if w == nil || w.destroyed {
1639+ return
1640+ }
1641+
1642+ domain := extractDomain(url)
1643+ if domain == "" {
1644+ return
1645+ }
1646+
1647+ // Get domain-specific cosmetic rules
1648+ script := filterManager.GetCosmeticScriptForDomain(domain)
1649+ if script != "" {
1650+ // Inject domain-specific cosmetic rules
1651+ logging.Info("[webkit] Applying domain-specific cosmetic rules for: " + domain + " (" + fmt.Sprintf("%d", len(script)) + " chars)")
1652+ w.InjectScript(fmt.Sprintf(
1653+ "if (typeof window.__dumber_cosmetic_init === 'function') { %s }",
1654+ script,
1655+ ))
1656+ } else {
1657+ logging.Debug("[webkit] No domain-specific cosmetic rules found for: " + domain)
1658+ }
1659+}
1660+
1661+// UpdateContentFilters updates the content filters dynamically
1662+func (w *WebView) UpdateContentFilters(filterManager FilterManager) error {
1663+ if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
1664+ return ErrWebViewNotInitialized
1665+ }
1666+
1667+ // Clear existing filters first
1668+ if err := w.ClearAllFilters(); err != nil {
1669+ return fmt.Errorf("failed to clear existing filters: %w", err)
1670+ }
1671+
1672+ // Apply new filters
1673+ if filters, err := filterManager.GetNetworkFilters(); err == nil {
1674+ logging.Info("[webkit] Updating network filters (" + fmt.Sprintf("%d", len(filters)) + " bytes)")
1675+ if err := w.ApplyContentFilters(filters, "network-filters-updated"); err != nil {
1676+ return fmt.Errorf("failed to apply updated network filters: %w", err)
1677+ }
1678+ }
1679+
1680+ // Update cosmetic filtering
1681+ script := filterManager.GetCosmeticScript()
1682+ logging.Info("[webkit] Updating cosmetic script (" + fmt.Sprintf("%d", len(script)) + " chars)")
1683+ if err := w.InjectCosmeticFilter(script); err != nil {
1684+ return fmt.Errorf("failed to inject updated cosmetic filter: %w", err)
1685+ }
1686+
1687+ logging.Info("Content filters updated successfully")
1688+ return nil
1689+}
1690+
1691+// extractDomain extracts the domain from a URL
1692+func extractDomain(url string) string {
1693+ if url == "" {
1694+ return ""
1695+ }
1696+
1697+ // Simple domain extraction - remove protocol and path
1698+ start := strings.Index(url, "://")
1699+ if start != -1 {
1700+ url = url[start+3:]
1701+ }
1702+
1703+ end := strings.Index(url, "/")
1704+ if end != -1 {
1705+ url = url[:end]
1706+ }
1707+
1708+ // Remove port if present
1709+ if portIdx := strings.Index(url, ":"); portIdx != -1 {
1710+ url = url[:portIdx]
1711+ }
1712+
1713+ return url
1714+}
M pkg/webkit/window_cgo.go +1 -0
@@ -4,6 +4,7 @@ package webkit
44
55 /*
66 #cgo pkg-config: webkitgtk-6.0 gtk4
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
78 #include <stdlib.h>
89 #include <gtk/gtk.h>
910 */
@@ -4,6 +4,7 @@ package webkit
4 4
5 /*5 /*
6 #cgo pkg-config: webkitgtk-6.0 gtk46 #cgo pkg-config: webkitgtk-6.0 gtk4
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
7 #include <stdlib.h>8 #include <stdlib.h>
8 #include <gtk/gtk.h>9 #include <gtk/gtk.h>
9 */10 */
M pkg/webkit/zoom_cgo.go +1 -0
@@ -4,6 +4,7 @@ package webkit
44
55 /*
66 #cgo pkg-config: webkitgtk-6.0 gtk4
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
78 #include <webkit/webkit.h>
89 */
910 import "C"
@@ -4,6 +4,7 @@ package webkit
4 4
5 /*5 /*
6 #cgo pkg-config: webkitgtk-6.0 gtk46 #cgo pkg-config: webkitgtk-6.0 gtk4
7+#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
7 #include <webkit/webkit.h>8 #include <webkit/webkit.h>
8 */9 */
9 import "C"10 import "C"

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close