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. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### 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 | 18 | - **Persistent TLS certificate validation system**: | |
| 9 | 19 | - **Three-option certificate dialog**: "Go Back", "Proceed Once (Unsafe)", and "Always Accept This Site" | |
| 10 | 20 | - **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 | ### Added | 7 | ### 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 hashing | 20 | - **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 | |||
| 13 | 13 | MAIN_PATH=. | |
| 14 | 14 | 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 | 19 | # Local caches to avoid $HOME permission issues (override via .env.local) | |
| 17 | 20 | GOMODCACHE?=$(CURDIR)/tmp/go-mod | |
| 18 | 21 | GOCACHE?=$(CURDIR)/tmp/go-cache | |
| @@ -34,9 +37,10 @@ help: ## Show this help message | |||
| 34 | 37 | ||
| 35 | 38 | # Build targets | |
| 36 | 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 | 41 | @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 | 45 | build-frontend: ## Build TypeScript frontend | |
| 42 | 46 | @echo "Building TypeScript frontend..." | |
| @@ -44,9 +48,10 @@ build-frontend: ## Build TypeScript frontend | |||
| 44 | 48 | @echo "Frontend build complete" | |
| 45 | 49 | ||
| 46 | 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 | 52 | @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 | 56 | build-static: build-no-gui ## Alias for build-no-gui (backward compatibility) | |
| 52 | 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 | 62 | @echo "Note: build-gui is now the default 'build' target" | |
| 58 | 63 | ||
| 59 | 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 | 66 | @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 | 69 | .PHONY: check-webkit | |
| 65 | 70 | 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=dist | 14 | 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-mod | 20 | GOMODCACHE?=$(CURDIR)/tmp/go-mod | ||
| 18 | GOCACHE?=$(CURDIR)/tmp/go-cache | 21 | GOCACHE?=$(CURDIR)/tmp/go-cache | ||
| @@ -34,9 +37,10 @@ help: ## Show this help message | |||||
| 34 | 37 | ||||
| 35 | # Build targets | 38 | # 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-mod | 41 | @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 frontend | 45 | 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-mod | 52 | @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-mod | 66 | @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-webkit | 69 | .PHONY: check-webkit | ||
| 65 | check-webkit: ## Check system has GTK4/WebKitGTK 6.0/JavaScriptCore 6.0 | 70 | 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 ( | |||
| 15 | 15 | "github.com/bnema/dumber/internal/app/messaging" | |
| 16 | 16 | "github.com/bnema/dumber/internal/config" | |
| 17 | 17 | "github.com/bnema/dumber/internal/db" | |
| 18 | + | "github.com/bnema/dumber/internal/filtering" | |
| 18 | 19 | "github.com/bnema/dumber/internal/logging" | |
| 19 | 20 | "github.com/bnema/dumber/pkg/webkit" | |
| 20 | 21 | "github.com/bnema/dumber/services" | |
| @@ -42,6 +43,9 @@ type BrowserApp struct { | |||
| 42 | 43 | navigationController *control.NavigationController | |
| 43 | 44 | clipboardController *control.ClipboardController | |
| 44 | 45 | ||
| 46 | + | // Content filtering | |
| 47 | + | filterManager *filtering.FilterManager | |
| 48 | + | ||
| 45 | 49 | // Handlers | |
| 46 | 50 | schemeHandler *api.SchemeHandler | |
| 47 | 51 | messageHandler *messaging.Handler | |
| @@ -142,6 +146,12 @@ func (app *BrowserApp) Run() { | |||
| 142 | 146 | 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 | 155 | // Handle browse command if present | |
| 146 | 156 | app.navigationController.HandleBrowseCommand() | |
| 147 | 157 | ||
| @@ -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.NavigationController | 43 | navigationController *control.NavigationController | ||
| 43 | clipboardController *control.ClipboardController | 44 | clipboardController *control.ClipboardController | ||
| 44 | 45 | ||||
| 46 | + | // Content filtering | |||
| 47 | + | filterManager *filtering.FilterManager | |||
| 48 | + | ||||
| 45 | // Handlers | 49 | // Handlers | ||
| 46 | schemeHandler *api.SchemeHandler | 50 | schemeHandler *api.SchemeHandler | ||
| 47 | messageHandler *messaging.Handler | 51 | messageHandler *messaging.Handler | ||
| @@ -142,6 +146,12 @@ func (app *BrowserApp) Run() { | |||||
| 142 | return | 146 | 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 present | 155 | // 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 ( | |||
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | 8 | "strings" | |
| 9 | + | "time" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/dumber/internal/app/constants" | |
| 11 | 12 | "github.com/bnema/dumber/internal/app/control" | |
| 12 | 13 | "github.com/bnema/dumber/internal/app/environment" | |
| 13 | 14 | "github.com/bnema/dumber/internal/config" | |
| 15 | + | "github.com/bnema/dumber/internal/filtering" | |
| 14 | 16 | "github.com/bnema/dumber/pkg/webkit" | |
| 15 | 17 | ) | |
| 16 | 18 | ||
| @@ -150,3 +152,79 @@ func (app *BrowserApp) setupKeyboardShortcuts() { | |||
| 150 | 152 | app.shortcutHandler = NewShortcutHandler(app.webView, app.clipboardController) | |
| 151 | 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 | + | } | |
| @@ -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 { | |||
| 28 | 28 | HistoryCache bool | |
| 29 | 29 | BrowserCache bool | |
| 30 | 30 | BrowserData bool | |
| 31 | + | FilterCache bool | |
| 31 | 32 | State bool | |
| 32 | 33 | Config bool | |
| 33 | 34 | All bool | |
| @@ -48,6 +49,7 @@ Available purge targets: | |||
| 48 | 49 | --history-cache, -H Purge dmenu fuzzy search cache for history | |
| 49 | 50 | --browser-cache, -c Purge WebKit browser cache (cached images, files, etc.) | |
| 50 | 51 | --browser-data, -b Purge WebKit browser data (cookies, localStorage, sessionStorage) | |
| 52 | + | --filter-cache, -F Purge compiled ad blocking filters cache | |
| 51 | 53 | --state, -s Purge all state data (includes database and caches) | |
| 52 | 54 | --config Purge configuration files | |
| 53 | 55 | --all, -a Purge everything (default if no specific flags are provided) | |
| @@ -57,8 +59,8 @@ Use --force to skip the confirmation prompt. | |||
| 57 | 59 | Examples: | |
| 58 | 60 | dumber purge # Purge everything (with confirmation) | |
| 59 | 61 | 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`, | |
| 62 | 64 | RunE: func(_ *cobra.Command, _ []string) error { | |
| 63 | 65 | // Initialize CLI to get config paths | |
| 64 | 66 | cli, err := NewCLI() | |
| @@ -80,6 +82,7 @@ Examples: | |||
| 80 | 82 | cmd.Flags().BoolVarP(&flags.HistoryCache, "history-cache", "H", false, "Purge dmenu fuzzy search cache") | |
| 81 | 83 | cmd.Flags().BoolVarP(&flags.BrowserCache, "browser-cache", "c", false, "Purge WebKit browser cache") | |
| 82 | 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 | 86 | cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data") | |
| 84 | 87 | cmd.Flags().BoolVar(&flags.Config, "config", false, "Purge configuration files") | |
| 85 | 88 | cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Purge everything") | |
| @@ -120,8 +123,8 @@ func determinePurgeItems(flags PurgeFlags) []string { | |||
| 120 | 123 | var items []string | |
| 121 | 124 | ||
| 122 | 125 | // 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"} | |
| 125 | 128 | } | |
| 126 | 129 | ||
| 127 | 130 | // Add items based on flags | |
| @@ -137,9 +140,12 @@ func determinePurgeItems(flags PurgeFlags) []string { | |||
| 137 | 140 | if flags.BrowserData { | |
| 138 | 141 | items = append(items, "browser-data") | |
| 139 | 142 | } | |
| 143 | + | if flags.FilterCache { | |
| 144 | + | items = append(items, "filter-cache") | |
| 145 | + | } | |
| 140 | 146 | 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") | |
| 143 | 149 | } | |
| 144 | 150 | if flags.Config { | |
| 145 | 151 | items = append(items, "config") | |
| @@ -195,6 +201,13 @@ func getPurgePaths(items []string) (map[string][]string, error) { | |||
| 195 | 201 | webkitData := filepath.Join(dataDir, "webkit") | |
| 196 | 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 | 211 | case "config": | |
| 199 | 212 | configFile, err := config.GetConfigFile() | |
| 200 | 213 | if err != nil { | |
| @@ -28,6 +28,7 @@ type PurgeFlags struct { | |||||
| 28 | HistoryCache bool | 28 | HistoryCache bool | ||
| 29 | BrowserCache bool | 29 | BrowserCache bool | ||
| 30 | BrowserData bool | 30 | BrowserData bool | ||
| 31 | + | FilterCache bool | |||
| 31 | State bool | 32 | State bool | ||
| 32 | Config bool | 33 | Config bool | ||
| 33 | All bool | 34 | All bool | ||
| @@ -48,6 +49,7 @@ Available purge targets: | |||||
| 48 | --history-cache, -H Purge dmenu fuzzy search cache for history | 49 | --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 files | 54 | --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 caches | 62 | + | 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 paths | 65 | // 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 []string | 123 | var items []string | ||
| 121 | 124 | ||||
| 122 | // If no specific flags are set, or --all is set, purge everything | 125 | // 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 flags | 130 | // 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 caches | 147 | + | // 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 { | |||
| 34 | 34 | VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"` | |
| 35 | 35 | CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences"` | |
| 36 | 36 | WebkitMemory WebkitMemoryConfig `mapstructure:"webkit_memory" yaml:"webkit_memory"` | |
| 37 | + | Debug DebugConfig `mapstructure:"debug" yaml:"debug"` | |
| 37 | 38 | // RenderingMode controls GPU/CPU rendering selection for WebKit | |
| 38 | 39 | RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"` | |
| 39 | 40 | } | |
| @@ -182,6 +183,31 @@ type WebkitMemoryConfig struct { | |||
| 182 | 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 | 211 | // Manager handles configuration loading, watching, and reloading. | |
| 186 | 212 | type Manager struct { | |
| 187 | 213 | 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 WebKit | 38 | // 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 *Config | 213 | config *Config | ||
M internal/config/defaults.go +9 -0
| @@ -131,6 +131,15 @@ func DefaultConfig() *Config { | |||
| 131 | 131 | ProcessRecycleThreshold: 50, // Recycle after 50 page loads | |
| 132 | 132 | 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 | 143 | RenderingMode: RenderingModeAuto, | |
| 135 | 144 | } | |
| 136 | 145 | } | |
| @@ -131,6 +131,15 @@ func DefaultConfig() *Config { | |||||
| 131 | ProcessRecycleThreshold: 50, // Recycle after 50 page loads | 131 | ProcessRecycleThreshold: 50, // Recycle after 50 page loads | ||
| 132 | EnableMemoryMonitoring: true, // Monitor for production tuning | 132 | 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) { | |||
| 109 | 109 | 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 | 131 | // EnsureDirectories creates the XDG directories if they don't exist. | |
| 113 | 132 | func EnsureDirectories() error { | |
| 114 | 133 | dirs, err := GetXDGDirs() | |
| @@ -109,6 +109,25 @@ func GetDatabaseFile() (string, error) { | |||||
| 109 | return filepath.Join(stateDir, "history.db"), nil | 109 | 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 | |||
| 4 | 4 | ||
| 5 | 5 | /* | |
| 6 | 6 | #cgo pkg-config: webkitgtk-6.0 gtk4 | |
| 7 | + | #cgo CFLAGS: -I/usr/include/webkitgtk-6.0 | |
| 7 | 8 | #include <webkit/webkit.h> | |
| 8 | 9 | #include <gtk/gtk.h> | |
| 9 | 10 | */ | |
| @@ -4,6 +4,7 @@ package webkit | |||||
| 4 | 4 | ||||
| 5 | /* | 5 | /* | ||
| 6 | #cgo pkg-config: webkitgtk-6.0 gtk4 | 6 | #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 | |||
| 4 | 4 | ||
| 5 | 5 | /* | |
| 6 | 6 | #cgo pkg-config: webkitgtk-6.0 gtk4 | |
| 7 | + | #cgo CFLAGS: -I/usr/include/webkitgtk-6.0 | |
| 7 | 8 | #include <webkit/webkit.h> | |
| 8 | 9 | */ | |
| 9 | 10 | import "C" | |
| @@ -4,6 +4,7 @@ package webkit | |||||
| 4 | 4 | ||||
| 5 | /* | 5 | /* | ||
| 6 | #cgo pkg-config: webkitgtk-6.0 gtk4 | 6 | #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" | |||
| 5 | 5 | ||
| 6 | 6 | // ErrNotImplemented is returned by stub implementations pending WebKit2GTK migration. | |
| 7 | 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") | |
| @@ -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 | |||
| 4 | 4 | ||
| 5 | 5 | /* | |
| 6 | 6 | #cgo pkg-config: webkitgtk-6.0 gtk4 | |
| 7 | + | #cgo CFLAGS: -I/usr/include/webkitgtk-6.0 | |
| 7 | 8 | #include <stdlib.h> | |
| 8 | 9 | #include <webkit/webkit.h> | |
| 9 | 10 | */ | |
| @@ -4,6 +4,7 @@ package webkit | |||||
| 4 | 4 | ||||
| 5 | /* | 5 | /* | ||
| 6 | #cgo pkg-config: webkitgtk-6.0 gtk4 | 6 | #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 | |||
| 4 | 4 | ||
| 5 | 5 | /* | |
| 6 | 6 | #cgo pkg-config: webkitgtk-6.0 gtk4 javascriptcoregtk-6.0 | |
| 7 | + | #cgo CFLAGS: -I/usr/include/webkitgtk-6.0 | |
| 7 | 8 | #include <stdlib.h> | |
| 8 | 9 | #include <string.h> | |
| 9 | 10 | #include <gtk/gtk.h> | |
| @@ -547,7 +548,7 @@ static void on_alert_dialog_choose_done(GObject *source, GAsyncResult *result, g | |||
| 547 | 548 | } | |
| 548 | 549 | data->completed = TRUE; | |
| 549 | 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 | 552 | response_names[data->response] : "UNKNOWN"; | |
| 552 | 553 | printf("[dumber] TLS dialog response: %s (button_index=%d)\n", response_name, data->response); | |
| 553 | 554 | fflush(stdout); | |
| @@ -564,31 +565,31 @@ static gboolean show_tls_warning_dialog_sync(GtkWindow *parent, const char* host | |||
| 564 | 565 | ||
| 565 | 566 | // Create the modern AlertDialog | |
| 566 | 567 | GtkAlertDialog *dialog = gtk_alert_dialog_new("Certificate Error for %s", hostname); | |
| 567 | - | ||
| 568 | + | ||
| 568 | 569 | // Set modal behavior and error details | |
| 569 | 570 | gtk_alert_dialog_set_modal(dialog, TRUE); | |
| 570 | 571 | gtk_alert_dialog_set_detail(dialog, error_msg); | |
| 571 | - | ||
| 572 | + | ||
| 572 | 573 | // Configure buttons (Go Back = 0, Proceed Once = 1, Always Accept = 2) | |
| 573 | 574 | const char *buttons[] = {"Go Back", "Proceed Once (Unsafe)", "Always Accept This Site", NULL}; | |
| 574 | 575 | gtk_alert_dialog_set_buttons(dialog, buttons); | |
| 575 | 576 | gtk_alert_dialog_set_default_button(dialog, 0); | |
| 576 | 577 | gtk_alert_dialog_set_cancel_button(dialog, 0); | |
| 577 | - | ||
| 578 | + | ||
| 578 | 579 | // Set up response data with main loop for sync behavior | |
| 579 | 580 | AlertDialogResponse response_data = {0, FALSE, g_main_loop_new(NULL, FALSE)}; | |
| 580 | - | ||
| 581 | + | ||
| 581 | 582 | // Start async operation | |
| 582 | - | gtk_alert_dialog_choose(dialog, parent, NULL, | |
| 583 | + | gtk_alert_dialog_choose(dialog, parent, NULL, | |
| 583 | 584 | on_alert_dialog_choose_done, &response_data); | |
| 584 | - | ||
| 585 | + | ||
| 585 | 586 | // Run event loop until dialog completes | |
| 586 | 587 | g_main_loop_run(response_data.loop); | |
| 587 | 588 | g_main_loop_unref(response_data.loop); | |
| 588 | - | ||
| 589 | + | ||
| 589 | 590 | // Clean up | |
| 590 | 591 | g_object_unref(dialog); | |
| 591 | - | ||
| 592 | + | ||
| 592 | 593 | // Return response code: 0=Go Back, 1=Proceed Once, 2=Always Accept | |
| 593 | 594 | return response_data.response; | |
| 594 | 595 | } | |
| @@ -605,14 +606,23 @@ import ( | |||
| 605 | 606 | "os" | |
| 606 | 607 | "path/filepath" | |
| 607 | 608 | "runtime" | |
| 609 | + | "strings" | |
| 608 | 610 | "time" | |
| 609 | 611 | "unsafe" | |
| 610 | 612 | ||
| 611 | 613 | "github.com/bnema/dumber/internal/config" | |
| 612 | 614 | "github.com/bnema/dumber/internal/db" | |
| 615 | + | "github.com/bnema/dumber/internal/logging" | |
| 613 | 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 | 626 | // Helper function to convert bool to int for C interop | |
| 617 | 627 | func boolToInt(b bool) int { | |
| 618 | 628 | if b { | |
| @@ -1481,12 +1491,12 @@ func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, cer | |||
| 1481 | 1491 | ) | |
| 1482 | 1492 | ||
| 1483 | 1493 | responseCode := int(result) | |
| 1484 | - | ||
| 1494 | + | ||
| 1485 | 1495 | switch responseCode { | |
| 1486 | 1496 | case 0: // Go Back | |
| 1487 | 1497 | log.Printf("[tls] User chose to GO BACK for %s", hostname) | |
| 1488 | 1498 | return false | |
| 1489 | - | ||
| 1499 | + | ||
| 1490 | 1500 | case 1: // Proceed Once | |
| 1491 | 1501 | log.Printf("[tls] User chose to PROCEED ONCE for %s", hostname) | |
| 1492 | 1502 | // Store temporary decision (expires in 24 hours) | |
| @@ -1494,7 +1504,7 @@ func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, cer | |||
| 1494 | 1504 | log.Printf("[tls] Warning: Failed to store temporary certificate decision: %v", err) | |
| 1495 | 1505 | } | |
| 1496 | 1506 | return true | |
| 1497 | - | ||
| 1507 | + | ||
| 1498 | 1508 | case 2: // Always Accept | |
| 1499 | 1509 | log.Printf("[tls] User chose to ALWAYS ACCEPT for %s", hostname) | |
| 1500 | 1510 | // Store permanent decision | |
| @@ -1502,7 +1512,7 @@ func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, cer | |||
| 1502 | 1512 | log.Printf("[tls] Warning: Failed to store permanent certificate decision: %v", err) | |
| 1503 | 1513 | } | |
| 1504 | 1514 | return true | |
| 1505 | - | ||
| 1515 | + | ||
| 1506 | 1516 | default: | |
| 1507 | 1517 | log.Printf("[tls] Unknown response code %d for %s, defaulting to reject", responseCode, hostname) | |
| 1508 | 1518 | return false | |
| @@ -1543,3 +1553,162 @@ func formatTLSErrorMessage(hostname, uri string, errorFlags int, certificateInfo | |||
| 1543 | 1553 | ||
| 1544 | 1554 | 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 | + | } | |
| @@ -4,6 +4,7 @@ package webkit | |||||
| 4 | 4 | ||||
| 5 | /* | 5 | /* | ||
| 6 | #cgo pkg-config: webkitgtk-6.0 gtk4 javascriptcoregtk-6.0 | 6 | #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 AlertDialog | 566 | // 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 details | 569 | // 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 behavior | 579 | // 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 operation | 582 | // 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 completes | 586 | // 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 up | 590 | // 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 Accept | 593 | // 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 interop | 626 | // 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 Back | 1496 | 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 false | 1498 | return false | ||
| 1489 | - | 1499 | + | ||
| 1490 | case 1: // Proceed Once | 1500 | 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 true | 1506 | return true | ||
| 1497 | - | 1507 | + | ||
| 1498 | case 2: // Always Accept | 1508 | 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 decision | 1510 | // 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 true | 1514 | 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 false | 1518 | return false | ||
| @@ -1543,3 +1553,162 @@ func formatTLSErrorMessage(hostname, uri string, errorFlags int, certificateInfo | |||||
| 1543 | 1553 | ||||
| 1544 | return msg | 1554 | 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 | |||
| 4 | 4 | ||
| 5 | 5 | /* | |
| 6 | 6 | #cgo pkg-config: webkitgtk-6.0 gtk4 | |
| 7 | + | #cgo CFLAGS: -I/usr/include/webkitgtk-6.0 | |
| 7 | 8 | #include <stdlib.h> | |
| 8 | 9 | #include <gtk/gtk.h> | |
| 9 | 10 | */ | |
| @@ -4,6 +4,7 @@ package webkit | |||||
| 4 | 4 | ||||
| 5 | /* | 5 | /* | ||
| 6 | #cgo pkg-config: webkitgtk-6.0 gtk4 | 6 | #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 | |||
| 4 | 4 | ||
| 5 | 5 | /* | |
| 6 | 6 | #cgo pkg-config: webkitgtk-6.0 gtk4 | |
| 7 | + | #cgo CFLAGS: -I/usr/include/webkitgtk-6.0 | |
| 7 | 8 | #include <webkit/webkit.h> | |
| 8 | 9 | */ | |
| 9 | 10 | import "C" | |
| @@ -4,6 +4,7 @@ package webkit | |||||
| 4 | 4 | ||||
| 5 | /* | 5 | /* | ||
| 6 | #cgo pkg-config: webkitgtk-6.0 gtk4 | 6 | #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" | ||