refactor(filtering): update manager to use interfaces instead of concrete types
- commit
- 92431e4d5835d18cdfcec5fd0152c1012e4ab4dd
- author
- brice · · 2025-12-18 21:27 UTC
- committer
- brice · · 2025-12-18 19:06 UTC
- parent
- a373232
- signature
- unsigned
1 file · +410 -0
A internal/infrastructure/filtering/manager.go +410 -0
| @@ -0,0 +1,410 @@ | |||
| 1 | + | package filtering | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "sync" | |
| 10 | + | "sync/atomic" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 13 | + | "github.com/bnema/puregotk-webkit/webkit" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Manager orchestrates the content filter lifecycle. | |
| 17 | + | // It handles downloading, compiling, loading, and applying filters to WebViews. | |
| 18 | + | type Manager struct { | |
| 19 | + | store FilterStore | |
| 20 | + | downloader FilterDownloader | |
| 21 | + | ||
| 22 | + | storeDir string | |
| 23 | + | jsonDir string | |
| 24 | + | ||
| 25 | + | filter *webkit.UserContentFilter | |
| 26 | + | filterMu sync.RWMutex | |
| 27 | + | status atomic.Value // FilterStatus | |
| 28 | + | enabled bool | |
| 29 | + | autoUpdate bool | |
| 30 | + | ||
| 31 | + | // Callbacks for status updates (e.g., toast notifications) | |
| 32 | + | onStatusChange func(FilterStatus) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // ManagerConfig holds configuration for the filter manager. | |
| 36 | + | type ManagerConfig struct { | |
| 37 | + | StoreDir string // Where to store compiled filter bytecode | |
| 38 | + | JSONDir string // Where to cache downloaded JSON files | |
| 39 | + | Enabled bool // Whether filtering is enabled | |
| 40 | + | AutoUpdate bool // Whether to auto-update filters | |
| 41 | + | ||
| 42 | + | // Optional: custom implementations for testing | |
| 43 | + | Store FilterStore // If nil, creates default Store | |
| 44 | + | Downloader FilterDownloader // If nil, creates default Downloader | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // NewManager creates a new filter Manager. | |
| 48 | + | func NewManager(cfg ManagerConfig) (*Manager, error) { | |
| 49 | + | // Ensure directories exist | |
| 50 | + | if err := os.MkdirAll(cfg.StoreDir, 0o755); err != nil { | |
| 51 | + | return nil, fmt.Errorf("failed to create store dir: %w", err) | |
| 52 | + | } | |
| 53 | + | if err := os.MkdirAll(cfg.JSONDir, 0o755); err != nil { | |
| 54 | + | return nil, fmt.Errorf("failed to create json dir: %w", err) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // Use provided implementations or create defaults | |
| 58 | + | store := cfg.Store | |
| 59 | + | if store == nil { | |
| 60 | + | store = NewStore(cfg.StoreDir) | |
| 61 | + | if store == nil { | |
| 62 | + | return nil, fmt.Errorf("failed to create filter store at %s", cfg.StoreDir) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | downloader := cfg.Downloader | |
| 67 | + | if downloader == nil { | |
| 68 | + | downloader = NewDownloader(cfg.JSONDir) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | m := &Manager{ | |
| 72 | + | store: store, | |
| 73 | + | downloader: downloader, | |
| 74 | + | storeDir: cfg.StoreDir, | |
| 75 | + | jsonDir: cfg.JSONDir, | |
| 76 | + | enabled: cfg.Enabled, | |
| 77 | + | autoUpdate: cfg.AutoUpdate, | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | m.setStatus(FilterStatus{State: StateUninitialized}) | |
| 81 | + | return m, nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // SetStatusCallback sets a callback for status changes (e.g., for toast notifications). | |
| 85 | + | func (m *Manager) SetStatusCallback(cb func(FilterStatus)) { | |
| 86 | + | m.onStatusChange = cb | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // setStatus updates the current status and notifies callback. | |
| 90 | + | func (m *Manager) setStatus(status FilterStatus) { | |
| 91 | + | m.status.Store(status) | |
| 92 | + | if m.onStatusChange != nil { | |
| 93 | + | m.onStatusChange(status) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Status returns the current filter status. | |
| 98 | + | func (m *Manager) Status() FilterStatus { | |
| 99 | + | if s, ok := m.status.Load().(FilterStatus); ok { | |
| 100 | + | return s | |
| 101 | + | } | |
| 102 | + | return FilterStatus{State: StateUninitialized} | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Initialize performs fast initialization. | |
| 106 | + | // This is called BEFORE GTK starts, so we cannot use GIO async operations. | |
| 107 | + | // Just sets up state - actual filter loading happens in LoadAsync after GTK starts. | |
| 108 | + | func (m *Manager) Initialize(ctx context.Context) error { | |
| 109 | + | log := logging.FromContext(ctx).With(). | |
| 110 | + | Str("component", "filter-manager"). | |
| 111 | + | Logger() | |
| 112 | + | ||
| 113 | + | if !m.enabled { | |
| 114 | + | log.Info().Msg("content filtering disabled") | |
| 115 | + | m.setStatus(FilterStatus{State: StateDisabled}) | |
| 116 | + | return nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | log.Debug().Msg("filter manager initialized (filters will load after GTK starts)") | |
| 120 | + | m.setStatus(FilterStatus{State: StateUninitialized, Message: "Filters pending"}) | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // LoadAsync loads or downloads filters in the background. | |
| 125 | + | // Call this after the browser window is shown (GTK main loop running). | |
| 126 | + | // First tries to load from cache, then downloads if needed. | |
| 127 | + | func (m *Manager) LoadAsync(ctx context.Context) { | |
| 128 | + | if !m.enabled { | |
| 129 | + | return | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | go func() { | |
| 133 | + | log := logging.FromContext(ctx).With(). | |
| 134 | + | Str("component", "filter-manager"). | |
| 135 | + | Logger() | |
| 136 | + | ||
| 137 | + | // If already loaded, skip | |
| 138 | + | m.filterMu.RLock() | |
| 139 | + | if m.filter != nil { | |
| 140 | + | m.filterMu.RUnlock() | |
| 141 | + | log.Debug().Msg("filters already loaded, skipping async load") | |
| 142 | + | return | |
| 143 | + | } | |
| 144 | + | m.filterMu.RUnlock() | |
| 145 | + | ||
| 146 | + | // Try to load from cache first (fast path) | |
| 147 | + | m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."}) | |
| 148 | + | if m.store.HasCompiledFilter(ctx, FilterIdentifier) { | |
| 149 | + | log.Debug().Msg("found compiled filter, loading from cache") | |
| 150 | + | filter, err := m.store.Load(ctx, FilterIdentifier) | |
| 151 | + | if err == nil && filter != nil { | |
| 152 | + | m.filterMu.Lock() | |
| 153 | + | m.filter = filter | |
| 154 | + | m.filterMu.Unlock() | |
| 155 | + | ||
| 156 | + | version := m.getCachedVersion() | |
| 157 | + | m.setStatus(FilterStatus{ | |
| 158 | + | State: StateActive, | |
| 159 | + | Message: "Filters active", | |
| 160 | + | Version: version, | |
| 161 | + | }) | |
| 162 | + | log.Info().Str("version", version).Msg("filters loaded from cache") | |
| 163 | + | return | |
| 164 | + | } | |
| 165 | + | log.Warn().Err(err).Msg("failed to load cached filter, will download") | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // Download filters | |
| 169 | + | m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."}) | |
| 170 | + | paths, err := m.downloader.DownloadFilters(ctx, func(p DownloadProgress) { | |
| 171 | + | msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total) | |
| 172 | + | m.setStatus(FilterStatus{State: StateLoading, Message: msg}) | |
| 173 | + | }) | |
| 174 | + | if err != nil { | |
| 175 | + | log.Error().Err(err).Msg("failed to download filters") | |
| 176 | + | m.setStatus(FilterStatus{State: StateError, Message: "Download failed"}) | |
| 177 | + | return | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."}) | |
| 181 | + | ||
| 182 | + | // Merge JSON files into one for compilation | |
| 183 | + | mergedPath, err := m.mergeJSONFiles(ctx, paths) | |
| 184 | + | if err != nil { | |
| 185 | + | log.Error().Err(err).Msg("failed to merge filter files") | |
| 186 | + | m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"}) | |
| 187 | + | return | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // Compile the merged filter | |
| 191 | + | filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath) | |
| 192 | + | if err != nil { | |
| 193 | + | log.Error().Err(err).Msg("failed to compile filters") | |
| 194 | + | m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"}) | |
| 195 | + | return | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | m.filterMu.Lock() | |
| 199 | + | m.filter = filter | |
| 200 | + | m.filterMu.Unlock() | |
| 201 | + | ||
| 202 | + | version := m.getCachedVersion() | |
| 203 | + | m.setStatus(FilterStatus{ | |
| 204 | + | State: StateActive, | |
| 205 | + | Message: "Filters active", | |
| 206 | + | Version: version, | |
| 207 | + | }) | |
| 208 | + | log.Info().Str("version", version).Msg("filters compiled and active") | |
| 209 | + | }() | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // mergeJSONFiles combines multiple JSON rule files into one. | |
| 213 | + | // WebKit's UserContentFilterStore expects a single JSON array. | |
| 214 | + | func (m *Manager) mergeJSONFiles(ctx context.Context, paths []string) (string, error) { | |
| 215 | + | log := logging.FromContext(ctx).With(). | |
| 216 | + | Str("component", "filter-manager"). | |
| 217 | + | Logger() | |
| 218 | + | ||
| 219 | + | var allRules []json.RawMessage | |
| 220 | + | ||
| 221 | + | for _, path := range paths { | |
| 222 | + | log.Debug().Str("path", path).Msg("reading filter file") | |
| 223 | + | ||
| 224 | + | data, err := os.ReadFile(path) | |
| 225 | + | if err != nil { | |
| 226 | + | return "", fmt.Errorf("failed to read %s: %w", path, err) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | var rules []json.RawMessage | |
| 230 | + | if err := json.Unmarshal(data, &rules); err != nil { | |
| 231 | + | return "", fmt.Errorf("failed to parse %s: %w", path, err) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | allRules = append(allRules, rules...) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | log.Debug().Int("total_rules", len(allRules)).Msg("merging filter rules") | |
| 238 | + | ||
| 239 | + | merged, err := json.Marshal(allRules) | |
| 240 | + | if err != nil { | |
| 241 | + | return "", fmt.Errorf("failed to marshal merged rules: %w", err) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // Write merged file | |
| 245 | + | mergedPath := filepath.Join(m.jsonDir, "merged.json") | |
| 246 | + | if err := os.WriteFile(mergedPath, merged, 0o644); err != nil { | |
| 247 | + | return "", fmt.Errorf("failed to write merged file: %w", err) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | return mergedPath, nil | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // getCachedVersion returns the version from the cached manifest. | |
| 254 | + | func (m *Manager) getCachedVersion() string { | |
| 255 | + | manifest, err := m.downloader.GetCachedManifest() | |
| 256 | + | if err != nil || manifest == nil { | |
| 257 | + | return "unknown" | |
| 258 | + | } | |
| 259 | + | return manifest.Version | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | // ApplyTo adds the active filter to a WebView's UserContentManager. | |
| 263 | + | // Safe to call even if filters are not yet loaded (no-op in that case). | |
| 264 | + | func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) { | |
| 265 | + | log := logging.FromContext(ctx).With(). | |
| 266 | + | Str("component", "filter-manager"). | |
| 267 | + | Logger() | |
| 268 | + | ||
| 269 | + | if ucm == nil { | |
| 270 | + | return | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | m.filterMu.RLock() | |
| 274 | + | filter := m.filter | |
| 275 | + | m.filterMu.RUnlock() | |
| 276 | + | ||
| 277 | + | if filter != nil { | |
| 278 | + | ucm.AddFilter(filter) | |
| 279 | + | log.Debug().Msg("content filter applied to webview") | |
| 280 | + | } else { | |
| 281 | + | log.Debug().Msg("no filter available to apply (filters still loading?)") | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // GetFilter returns the currently loaded filter, or nil if none. | |
| 286 | + | func (m *Manager) GetFilter() *webkit.UserContentFilter { | |
| 287 | + | m.filterMu.RLock() | |
| 288 | + | defer m.filterMu.RUnlock() | |
| 289 | + | return m.filter | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | // CheckForUpdates checks if newer filters are available and downloads them. | |
| 293 | + | // This should be called periodically in the background. | |
| 294 | + | func (m *Manager) CheckForUpdates(ctx context.Context) error { | |
| 295 | + | if !m.enabled || !m.autoUpdate { | |
| 296 | + | return nil | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | log := logging.FromContext(ctx).With(). | |
| 300 | + | Str("component", "filter-manager"). | |
| 301 | + | Logger() | |
| 302 | + | ||
| 303 | + | needsUpdate, err := m.downloader.NeedsUpdate(ctx) | |
| 304 | + | if err != nil { | |
| 305 | + | log.Warn().Err(err).Msg("failed to check for filter updates") | |
| 306 | + | return err | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | if !needsUpdate { | |
| 310 | + | log.Debug().Msg("filters are up to date") | |
| 311 | + | return nil | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | log.Info().Msg("filter update available, downloading") | |
| 315 | + | ||
| 316 | + | // Download new filters | |
| 317 | + | paths, err := m.downloader.DownloadFilters(ctx, nil) | |
| 318 | + | if err != nil { | |
| 319 | + | return fmt.Errorf("failed to download filter update: %w", err) | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // Merge and compile | |
| 323 | + | mergedPath, err := m.mergeJSONFiles(ctx, paths) | |
| 324 | + | if err != nil { | |
| 325 | + | return fmt.Errorf("failed to merge updated filters: %w", err) | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath) | |
| 329 | + | if err != nil { | |
| 330 | + | return fmt.Errorf("failed to compile updated filters: %w", err) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | m.filterMu.Lock() | |
| 334 | + | m.filter = filter | |
| 335 | + | m.filterMu.Unlock() | |
| 336 | + | ||
| 337 | + | version := m.getCachedVersion() | |
| 338 | + | m.setStatus(FilterStatus{ | |
| 339 | + | State: StateActive, | |
| 340 | + | Message: fmt.Sprintf("Filters updated to %s", version), | |
| 341 | + | Version: version, | |
| 342 | + | }) | |
| 343 | + | log.Info().Str("version", version).Msg("filters updated successfully") | |
| 344 | + | ||
| 345 | + | return nil | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // Enable enables content filtering. | |
| 349 | + | func (m *Manager) Enable(ctx context.Context) { | |
| 350 | + | log := logging.FromContext(ctx).With(). | |
| 351 | + | Str("component", "filter-manager"). | |
| 352 | + | Logger() | |
| 353 | + | ||
| 354 | + | m.enabled = true | |
| 355 | + | log.Info().Msg("content filtering enabled") | |
| 356 | + | ||
| 357 | + | // Try to load filters if not already loaded | |
| 358 | + | m.filterMu.RLock() | |
| 359 | + | hasFilter := m.filter != nil | |
| 360 | + | m.filterMu.RUnlock() | |
| 361 | + | ||
| 362 | + | if !hasFilter { | |
| 363 | + | m.LoadAsync(ctx) | |
| 364 | + | } else { | |
| 365 | + | m.setStatus(FilterStatus{ | |
| 366 | + | State: StateActive, | |
| 367 | + | Message: "Filters active", | |
| 368 | + | Version: m.getCachedVersion(), | |
| 369 | + | }) | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | // Disable disables content filtering. | |
| 374 | + | func (m *Manager) Disable(ctx context.Context) { | |
| 375 | + | log := logging.FromContext(ctx).With(). | |
| 376 | + | Str("component", "filter-manager"). | |
| 377 | + | Logger() | |
| 378 | + | ||
| 379 | + | m.enabled = false | |
| 380 | + | m.setStatus(FilterStatus{State: StateDisabled}) | |
| 381 | + | log.Info().Msg("content filtering disabled") | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // IsEnabled returns whether content filtering is enabled. | |
| 385 | + | func (m *Manager) IsEnabled() bool { | |
| 386 | + | return m.enabled | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | // Clear removes all compiled and cached filters. | |
| 390 | + | func (m *Manager) Clear(ctx context.Context) error { | |
| 391 | + | log := logging.FromContext(ctx).With(). | |
| 392 | + | Str("component", "filter-manager"). | |
| 393 | + | Logger() | |
| 394 | + | ||
| 395 | + | m.filterMu.Lock() | |
| 396 | + | m.filter = nil | |
| 397 | + | m.filterMu.Unlock() | |
| 398 | + | ||
| 399 | + | if err := m.store.Remove(ctx, FilterIdentifier); err != nil { | |
| 400 | + | log.Warn().Err(err).Msg("failed to remove compiled filter") | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | if err := m.downloader.ClearCache(); err != nil { | |
| 404 | + | log.Warn().Err(err).Msg("failed to clear download cache") | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | m.setStatus(FilterStatus{State: StateUninitialized}) | |
| 408 | + | log.Info().Msg("filters cleared") | |
| 409 | + | return nil | |
| 410 | + | } | |
| @@ -0,0 +1,410 @@ | |||||
| 1 | + | package filtering | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "sync" | |||
| 10 | + | "sync/atomic" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |||
| 13 | + | "github.com/bnema/puregotk-webkit/webkit" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // Manager orchestrates the content filter lifecycle. | |||
| 17 | + | // It handles downloading, compiling, loading, and applying filters to WebViews. | |||
| 18 | + | type Manager struct { | |||
| 19 | + | store FilterStore | |||
| 20 | + | downloader FilterDownloader | |||
| 21 | + | ||||
| 22 | + | storeDir string | |||
| 23 | + | jsonDir string | |||
| 24 | + | ||||
| 25 | + | filter *webkit.UserContentFilter | |||
| 26 | + | filterMu sync.RWMutex | |||
| 27 | + | status atomic.Value // FilterStatus | |||
| 28 | + | enabled bool | |||
| 29 | + | autoUpdate bool | |||
| 30 | + | ||||
| 31 | + | // Callbacks for status updates (e.g., toast notifications) | |||
| 32 | + | onStatusChange func(FilterStatus) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // ManagerConfig holds configuration for the filter manager. | |||
| 36 | + | type ManagerConfig struct { | |||
| 37 | + | StoreDir string // Where to store compiled filter bytecode | |||
| 38 | + | JSONDir string // Where to cache downloaded JSON files | |||
| 39 | + | Enabled bool // Whether filtering is enabled | |||
| 40 | + | AutoUpdate bool // Whether to auto-update filters | |||
| 41 | + | ||||
| 42 | + | // Optional: custom implementations for testing | |||
| 43 | + | Store FilterStore // If nil, creates default Store | |||
| 44 | + | Downloader FilterDownloader // If nil, creates default Downloader | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // NewManager creates a new filter Manager. | |||
| 48 | + | func NewManager(cfg ManagerConfig) (*Manager, error) { | |||
| 49 | + | // Ensure directories exist | |||
| 50 | + | if err := os.MkdirAll(cfg.StoreDir, 0o755); err != nil { | |||
| 51 | + | return nil, fmt.Errorf("failed to create store dir: %w", err) | |||
| 52 | + | } | |||
| 53 | + | if err := os.MkdirAll(cfg.JSONDir, 0o755); err != nil { | |||
| 54 | + | return nil, fmt.Errorf("failed to create json dir: %w", err) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // Use provided implementations or create defaults | |||
| 58 | + | store := cfg.Store | |||
| 59 | + | if store == nil { | |||
| 60 | + | store = NewStore(cfg.StoreDir) | |||
| 61 | + | if store == nil { | |||
| 62 | + | return nil, fmt.Errorf("failed to create filter store at %s", cfg.StoreDir) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | downloader := cfg.Downloader | |||
| 67 | + | if downloader == nil { | |||
| 68 | + | downloader = NewDownloader(cfg.JSONDir) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | m := &Manager{ | |||
| 72 | + | store: store, | |||
| 73 | + | downloader: downloader, | |||
| 74 | + | storeDir: cfg.StoreDir, | |||
| 75 | + | jsonDir: cfg.JSONDir, | |||
| 76 | + | enabled: cfg.Enabled, | |||
| 77 | + | autoUpdate: cfg.AutoUpdate, | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | m.setStatus(FilterStatus{State: StateUninitialized}) | |||
| 81 | + | return m, nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // SetStatusCallback sets a callback for status changes (e.g., for toast notifications). | |||
| 85 | + | func (m *Manager) SetStatusCallback(cb func(FilterStatus)) { | |||
| 86 | + | m.onStatusChange = cb | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // setStatus updates the current status and notifies callback. | |||
| 90 | + | func (m *Manager) setStatus(status FilterStatus) { | |||
| 91 | + | m.status.Store(status) | |||
| 92 | + | if m.onStatusChange != nil { | |||
| 93 | + | m.onStatusChange(status) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // Status returns the current filter status. | |||
| 98 | + | func (m *Manager) Status() FilterStatus { | |||
| 99 | + | if s, ok := m.status.Load().(FilterStatus); ok { | |||
| 100 | + | return s | |||
| 101 | + | } | |||
| 102 | + | return FilterStatus{State: StateUninitialized} | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Initialize performs fast initialization. | |||
| 106 | + | // This is called BEFORE GTK starts, so we cannot use GIO async operations. | |||
| 107 | + | // Just sets up state - actual filter loading happens in LoadAsync after GTK starts. | |||
| 108 | + | func (m *Manager) Initialize(ctx context.Context) error { | |||
| 109 | + | log := logging.FromContext(ctx).With(). | |||
| 110 | + | Str("component", "filter-manager"). | |||
| 111 | + | Logger() | |||
| 112 | + | ||||
| 113 | + | if !m.enabled { | |||
| 114 | + | log.Info().Msg("content filtering disabled") | |||
| 115 | + | m.setStatus(FilterStatus{State: StateDisabled}) | |||
| 116 | + | return nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | log.Debug().Msg("filter manager initialized (filters will load after GTK starts)") | |||
| 120 | + | m.setStatus(FilterStatus{State: StateUninitialized, Message: "Filters pending"}) | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // LoadAsync loads or downloads filters in the background. | |||
| 125 | + | // Call this after the browser window is shown (GTK main loop running). | |||
| 126 | + | // First tries to load from cache, then downloads if needed. | |||
| 127 | + | func (m *Manager) LoadAsync(ctx context.Context) { | |||
| 128 | + | if !m.enabled { | |||
| 129 | + | return | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | go func() { | |||
| 133 | + | log := logging.FromContext(ctx).With(). | |||
| 134 | + | Str("component", "filter-manager"). | |||
| 135 | + | Logger() | |||
| 136 | + | ||||
| 137 | + | // If already loaded, skip | |||
| 138 | + | m.filterMu.RLock() | |||
| 139 | + | if m.filter != nil { | |||
| 140 | + | m.filterMu.RUnlock() | |||
| 141 | + | log.Debug().Msg("filters already loaded, skipping async load") | |||
| 142 | + | return | |||
| 143 | + | } | |||
| 144 | + | m.filterMu.RUnlock() | |||
| 145 | + | ||||
| 146 | + | // Try to load from cache first (fast path) | |||
| 147 | + | m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."}) | |||
| 148 | + | if m.store.HasCompiledFilter(ctx, FilterIdentifier) { | |||
| 149 | + | log.Debug().Msg("found compiled filter, loading from cache") | |||
| 150 | + | filter, err := m.store.Load(ctx, FilterIdentifier) | |||
| 151 | + | if err == nil && filter != nil { | |||
| 152 | + | m.filterMu.Lock() | |||
| 153 | + | m.filter = filter | |||
| 154 | + | m.filterMu.Unlock() | |||
| 155 | + | ||||
| 156 | + | version := m.getCachedVersion() | |||
| 157 | + | m.setStatus(FilterStatus{ | |||
| 158 | + | State: StateActive, | |||
| 159 | + | Message: "Filters active", | |||
| 160 | + | Version: version, | |||
| 161 | + | }) | |||
| 162 | + | log.Info().Str("version", version).Msg("filters loaded from cache") | |||
| 163 | + | return | |||
| 164 | + | } | |||
| 165 | + | log.Warn().Err(err).Msg("failed to load cached filter, will download") | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // Download filters | |||
| 169 | + | m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."}) | |||
| 170 | + | paths, err := m.downloader.DownloadFilters(ctx, func(p DownloadProgress) { | |||
| 171 | + | msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total) | |||
| 172 | + | m.setStatus(FilterStatus{State: StateLoading, Message: msg}) | |||
| 173 | + | }) | |||
| 174 | + | if err != nil { | |||
| 175 | + | log.Error().Err(err).Msg("failed to download filters") | |||
| 176 | + | m.setStatus(FilterStatus{State: StateError, Message: "Download failed"}) | |||
| 177 | + | return | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."}) | |||
| 181 | + | ||||
| 182 | + | // Merge JSON files into one for compilation | |||
| 183 | + | mergedPath, err := m.mergeJSONFiles(ctx, paths) | |||
| 184 | + | if err != nil { | |||
| 185 | + | log.Error().Err(err).Msg("failed to merge filter files") | |||
| 186 | + | m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"}) | |||
| 187 | + | return | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // Compile the merged filter | |||
| 191 | + | filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath) | |||
| 192 | + | if err != nil { | |||
| 193 | + | log.Error().Err(err).Msg("failed to compile filters") | |||
| 194 | + | m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"}) | |||
| 195 | + | return | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | m.filterMu.Lock() | |||
| 199 | + | m.filter = filter | |||
| 200 | + | m.filterMu.Unlock() | |||
| 201 | + | ||||
| 202 | + | version := m.getCachedVersion() | |||
| 203 | + | m.setStatus(FilterStatus{ | |||
| 204 | + | State: StateActive, | |||
| 205 | + | Message: "Filters active", | |||
| 206 | + | Version: version, | |||
| 207 | + | }) | |||
| 208 | + | log.Info().Str("version", version).Msg("filters compiled and active") | |||
| 209 | + | }() | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | // mergeJSONFiles combines multiple JSON rule files into one. | |||
| 213 | + | // WebKit's UserContentFilterStore expects a single JSON array. | |||
| 214 | + | func (m *Manager) mergeJSONFiles(ctx context.Context, paths []string) (string, error) { | |||
| 215 | + | log := logging.FromContext(ctx).With(). | |||
| 216 | + | Str("component", "filter-manager"). | |||
| 217 | + | Logger() | |||
| 218 | + | ||||
| 219 | + | var allRules []json.RawMessage | |||
| 220 | + | ||||
| 221 | + | for _, path := range paths { | |||
| 222 | + | log.Debug().Str("path", path).Msg("reading filter file") | |||
| 223 | + | ||||
| 224 | + | data, err := os.ReadFile(path) | |||
| 225 | + | if err != nil { | |||
| 226 | + | return "", fmt.Errorf("failed to read %s: %w", path, err) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | var rules []json.RawMessage | |||
| 230 | + | if err := json.Unmarshal(data, &rules); err != nil { | |||
| 231 | + | return "", fmt.Errorf("failed to parse %s: %w", path, err) | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | allRules = append(allRules, rules...) | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | log.Debug().Int("total_rules", len(allRules)).Msg("merging filter rules") | |||
| 238 | + | ||||
| 239 | + | merged, err := json.Marshal(allRules) | |||
| 240 | + | if err != nil { | |||
| 241 | + | return "", fmt.Errorf("failed to marshal merged rules: %w", err) | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | // Write merged file | |||
| 245 | + | mergedPath := filepath.Join(m.jsonDir, "merged.json") | |||
| 246 | + | if err := os.WriteFile(mergedPath, merged, 0o644); err != nil { | |||
| 247 | + | return "", fmt.Errorf("failed to write merged file: %w", err) | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | return mergedPath, nil | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | // getCachedVersion returns the version from the cached manifest. | |||
| 254 | + | func (m *Manager) getCachedVersion() string { | |||
| 255 | + | manifest, err := m.downloader.GetCachedManifest() | |||
| 256 | + | if err != nil || manifest == nil { | |||
| 257 | + | return "unknown" | |||
| 258 | + | } | |||
| 259 | + | return manifest.Version | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | // ApplyTo adds the active filter to a WebView's UserContentManager. | |||
| 263 | + | // Safe to call even if filters are not yet loaded (no-op in that case). | |||
| 264 | + | func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) { | |||
| 265 | + | log := logging.FromContext(ctx).With(). | |||
| 266 | + | Str("component", "filter-manager"). | |||
| 267 | + | Logger() | |||
| 268 | + | ||||
| 269 | + | if ucm == nil { | |||
| 270 | + | return | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | m.filterMu.RLock() | |||
| 274 | + | filter := m.filter | |||
| 275 | + | m.filterMu.RUnlock() | |||
| 276 | + | ||||
| 277 | + | if filter != nil { | |||
| 278 | + | ucm.AddFilter(filter) | |||
| 279 | + | log.Debug().Msg("content filter applied to webview") | |||
| 280 | + | } else { | |||
| 281 | + | log.Debug().Msg("no filter available to apply (filters still loading?)") | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // GetFilter returns the currently loaded filter, or nil if none. | |||
| 286 | + | func (m *Manager) GetFilter() *webkit.UserContentFilter { | |||
| 287 | + | m.filterMu.RLock() | |||
| 288 | + | defer m.filterMu.RUnlock() | |||
| 289 | + | return m.filter | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // CheckForUpdates checks if newer filters are available and downloads them. | |||
| 293 | + | // This should be called periodically in the background. | |||
| 294 | + | func (m *Manager) CheckForUpdates(ctx context.Context) error { | |||
| 295 | + | if !m.enabled || !m.autoUpdate { | |||
| 296 | + | return nil | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | log := logging.FromContext(ctx).With(). | |||
| 300 | + | Str("component", "filter-manager"). | |||
| 301 | + | Logger() | |||
| 302 | + | ||||
| 303 | + | needsUpdate, err := m.downloader.NeedsUpdate(ctx) | |||
| 304 | + | if err != nil { | |||
| 305 | + | log.Warn().Err(err).Msg("failed to check for filter updates") | |||
| 306 | + | return err | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | if !needsUpdate { | |||
| 310 | + | log.Debug().Msg("filters are up to date") | |||
| 311 | + | return nil | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | log.Info().Msg("filter update available, downloading") | |||
| 315 | + | ||||
| 316 | + | // Download new filters | |||
| 317 | + | paths, err := m.downloader.DownloadFilters(ctx, nil) | |||
| 318 | + | if err != nil { | |||
| 319 | + | return fmt.Errorf("failed to download filter update: %w", err) | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | // Merge and compile | |||
| 323 | + | mergedPath, err := m.mergeJSONFiles(ctx, paths) | |||
| 324 | + | if err != nil { | |||
| 325 | + | return fmt.Errorf("failed to merge updated filters: %w", err) | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath) | |||
| 329 | + | if err != nil { | |||
| 330 | + | return fmt.Errorf("failed to compile updated filters: %w", err) | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | m.filterMu.Lock() | |||
| 334 | + | m.filter = filter | |||
| 335 | + | m.filterMu.Unlock() | |||
| 336 | + | ||||
| 337 | + | version := m.getCachedVersion() | |||
| 338 | + | m.setStatus(FilterStatus{ | |||
| 339 | + | State: StateActive, | |||
| 340 | + | Message: fmt.Sprintf("Filters updated to %s", version), | |||
| 341 | + | Version: version, | |||
| 342 | + | }) | |||
| 343 | + | log.Info().Str("version", version).Msg("filters updated successfully") | |||
| 344 | + | ||||
| 345 | + | return nil | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | // Enable enables content filtering. | |||
| 349 | + | func (m *Manager) Enable(ctx context.Context) { | |||
| 350 | + | log := logging.FromContext(ctx).With(). | |||
| 351 | + | Str("component", "filter-manager"). | |||
| 352 | + | Logger() | |||
| 353 | + | ||||
| 354 | + | m.enabled = true | |||
| 355 | + | log.Info().Msg("content filtering enabled") | |||
| 356 | + | ||||
| 357 | + | // Try to load filters if not already loaded | |||
| 358 | + | m.filterMu.RLock() | |||
| 359 | + | hasFilter := m.filter != nil | |||
| 360 | + | m.filterMu.RUnlock() | |||
| 361 | + | ||||
| 362 | + | if !hasFilter { | |||
| 363 | + | m.LoadAsync(ctx) | |||
| 364 | + | } else { | |||
| 365 | + | m.setStatus(FilterStatus{ | |||
| 366 | + | State: StateActive, | |||
| 367 | + | Message: "Filters active", | |||
| 368 | + | Version: m.getCachedVersion(), | |||
| 369 | + | }) | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | // Disable disables content filtering. | |||
| 374 | + | func (m *Manager) Disable(ctx context.Context) { | |||
| 375 | + | log := logging.FromContext(ctx).With(). | |||
| 376 | + | Str("component", "filter-manager"). | |||
| 377 | + | Logger() | |||
| 378 | + | ||||
| 379 | + | m.enabled = false | |||
| 380 | + | m.setStatus(FilterStatus{State: StateDisabled}) | |||
| 381 | + | log.Info().Msg("content filtering disabled") | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | // IsEnabled returns whether content filtering is enabled. | |||
| 385 | + | func (m *Manager) IsEnabled() bool { | |||
| 386 | + | return m.enabled | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | // Clear removes all compiled and cached filters. | |||
| 390 | + | func (m *Manager) Clear(ctx context.Context) error { | |||
| 391 | + | log := logging.FromContext(ctx).With(). | |||
| 392 | + | Str("component", "filter-manager"). | |||
| 393 | + | Logger() | |||
| 394 | + | ||||
| 395 | + | m.filterMu.Lock() | |||
| 396 | + | m.filter = nil | |||
| 397 | + | m.filterMu.Unlock() | |||
| 398 | + | ||||
| 399 | + | if err := m.store.Remove(ctx, FilterIdentifier); err != nil { | |||
| 400 | + | log.Warn().Err(err).Msg("failed to remove compiled filter") | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | if err := m.downloader.ClearCache(); err != nil { | |||
| 404 | + | log.Warn().Err(err).Msg("failed to clear download cache") | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | m.setStatus(FilterStatus{State: StateUninitialized}) | |||
| 408 | + | log.Info().Msg("filters cleared") | |||
| 409 | + | return nil | |||
| 410 | + | } | |||