detached 459eca0

Merge pull request #205 from bnema/fix/dynamic-filter-files

fix: dynamic filter file discovery and per-part compilation
commit
459eca049384f694d7b66ba7d93613a8a6b6431e
author
bnema · · 2026-03-27 13:36 UTC
committer
GitHub · · 2026-03-27 13:36 UTC
merge of
2d0c399 + 29bdfb1 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +179 -155
M internal/infrastructure/filtering/downloader.go +30 -16
@@ -106,8 +106,8 @@ type DownloadProgress struct {
106106 BytesTotal int64
107107 }
108108
109-// DownloadFilters downloads filter JSON files from GitHub releases.
110-// Returns the paths to the downloaded files.
109+// DownloadFilters fetches the manifest to discover which filter files exist,
110+// then downloads them. Returns the paths to the downloaded files.
111111 func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(DownloadProgress)) ([]string, error) {
112112 log := logging.FromContext(ctx).With().
113113 Str("component", "filter-downloader").
@@ -118,7 +118,22 @@ func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(Downlo
118118 return nil, fmt.Errorf("failed to create cache dir: %w", err)
119119 }
120120
121- files := FilterFiles.Combined
121+ // Fetch manifest to discover which combined files to download
122+ manifest, err := d.FetchManifest(ctx)
123+ if err != nil {
124+ return nil, fmt.Errorf("failed to fetch manifest: %w", err)
125+ }
126+ if len(manifest.Combined.Files) == 0 {
127+ return nil, fmt.Errorf("manifest contains no combined filter files")
128+ }
129+
130+ log.Info().
131+ Int("files", len(manifest.Combined.Files)).
132+ Int("total_rules", manifest.Combined.TotalRules).
133+ Str("version", manifest.Version).
134+ Msg("manifest lists filter files")
135+
136+ files := manifest.Combined.Files
122137 paths := make([]string, 0, len(files))
123138 var totalBytes int64
124139
@@ -149,10 +164,9 @@ func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(Downlo
149164 log.Debug().Str("file", filename).Int64("bytes", bytesDownloaded).Msg("downloaded filter file")
150165 }
151166
152- // Also download and cache the manifest
153- if err := d.downloadAndCacheManifest(ctx); err != nil {
167+ // Cache the manifest we already fetched
168+ if err := d.cacheManifest(manifest); err != nil {
154169 log.Warn().Err(err).Msg("failed to cache manifest")
155- // Non-fatal, continue
156170 }
157171
158172 log.Info().Int("files", len(paths)).Int64("total_bytes", totalBytes).Msg("filter download complete")
@@ -223,13 +237,8 @@ func (*Downloader) renameTempFile(tmpPath, localPath string) error {
223237 return nil
224238 }
225239
226-// downloadAndCacheManifest downloads and caches the manifest.
227-func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error {
228- manifest, err := d.FetchManifest(ctx)
229- if err != nil {
230- return err
231- }
232-
240+// cacheManifest writes an already-fetched manifest to disk.
241+func (d *Downloader) cacheManifest(manifest *Manifest) error {
233242 data, err := json.MarshalIndent(manifest, "", " ")
234243 if err != nil {
235244 return fmt.Errorf("failed to marshal manifest: %w", err)
@@ -244,10 +253,15 @@ func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error {
244253 }
245254
246255 // GetCachedFilterPaths returns paths to cached filter JSON files.
247-// Returns nil if any files are missing.
256+// Reads the cached manifest to discover which files should exist.
257+// Returns nil if the manifest is missing or any filter file is missing.
248258 func (d *Downloader) GetCachedFilterPaths() []string {
249- paths := make([]string, 0, len(FilterFiles.Combined))
250- for _, filename := range FilterFiles.Combined {
259+ manifest, err := d.GetCachedManifest()
260+ if err != nil || manifest == nil || len(manifest.Combined.Files) == 0 {
261+ return nil
262+ }
263+ paths := make([]string, 0, len(manifest.Combined.Files))
264+ for _, filename := range manifest.Combined.Files {
251265 path := filepath.Join(d.cacheDir, filename)
252266 if _, err := os.Stat(path); err != nil {
253267 return nil
@@ -106,8 +106,8 @@ type DownloadProgress struct {
106 BytesTotal int64106 BytesTotal int64
107 }107 }
108 108
109-// DownloadFilters downloads filter JSON files from GitHub releases.109+// DownloadFilters fetches the manifest to discover which filter files exist,
110-// Returns the paths to the downloaded files.110+// then downloads them. Returns the paths to the downloaded files.
111 func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(DownloadProgress)) ([]string, error) {111 func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(DownloadProgress)) ([]string, error) {
112 log := logging.FromContext(ctx).With().112 log := logging.FromContext(ctx).With().
113 Str("component", "filter-downloader").113 Str("component", "filter-downloader").
@@ -118,7 +118,22 @@ func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(Downlo
118 return nil, fmt.Errorf("failed to create cache dir: %w", err)118 return nil, fmt.Errorf("failed to create cache dir: %w", err)
119 }119 }
120 120
121- files := FilterFiles.Combined121+ // Fetch manifest to discover which combined files to download
122+ manifest, err := d.FetchManifest(ctx)
123+ if err != nil {
124+ return nil, fmt.Errorf("failed to fetch manifest: %w", err)
125+ }
126+ if len(manifest.Combined.Files) == 0 {
127+ return nil, fmt.Errorf("manifest contains no combined filter files")
128+ }
129+
130+ log.Info().
131+ Int("files", len(manifest.Combined.Files)).
132+ Int("total_rules", manifest.Combined.TotalRules).
133+ Str("version", manifest.Version).
134+ Msg("manifest lists filter files")
135+
136+ files := manifest.Combined.Files
122 paths := make([]string, 0, len(files))137 paths := make([]string, 0, len(files))
123 var totalBytes int64138 var totalBytes int64
124 139
@@ -149,10 +164,9 @@ func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(Downlo
149 log.Debug().Str("file", filename).Int64("bytes", bytesDownloaded).Msg("downloaded filter file")164 log.Debug().Str("file", filename).Int64("bytes", bytesDownloaded).Msg("downloaded filter file")
150 }165 }
151 166
152- // Also download and cache the manifest167+ // Cache the manifest we already fetched
153- if err := d.downloadAndCacheManifest(ctx); err != nil {168+ if err := d.cacheManifest(manifest); err != nil {
154 log.Warn().Err(err).Msg("failed to cache manifest")169 log.Warn().Err(err).Msg("failed to cache manifest")
155- // Non-fatal, continue
156 }170 }
157 171
158 log.Info().Int("files", len(paths)).Int64("total_bytes", totalBytes).Msg("filter download complete")172 log.Info().Int("files", len(paths)).Int64("total_bytes", totalBytes).Msg("filter download complete")
@@ -223,13 +237,8 @@ func (*Downloader) renameTempFile(tmpPath, localPath string) error {
223 return nil237 return nil
224 }238 }
225 239
226-// downloadAndCacheManifest downloads and caches the manifest.240+// cacheManifest writes an already-fetched manifest to disk.
227-func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error {241+func (d *Downloader) cacheManifest(manifest *Manifest) error {
228- manifest, err := d.FetchManifest(ctx)
229- if err != nil {
230- return err
231- }
232-
233 data, err := json.MarshalIndent(manifest, "", " ")242 data, err := json.MarshalIndent(manifest, "", " ")
234 if err != nil {243 if err != nil {
235 return fmt.Errorf("failed to marshal manifest: %w", err)244 return fmt.Errorf("failed to marshal manifest: %w", err)
@@ -244,10 +253,15 @@ func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error {
244 }253 }
245 254
246 // GetCachedFilterPaths returns paths to cached filter JSON files.255 // GetCachedFilterPaths returns paths to cached filter JSON files.
247-// Returns nil if any files are missing.256+// Reads the cached manifest to discover which files should exist.
257+// Returns nil if the manifest is missing or any filter file is missing.
248 func (d *Downloader) GetCachedFilterPaths() []string {258 func (d *Downloader) GetCachedFilterPaths() []string {
249- paths := make([]string, 0, len(FilterFiles.Combined))259+ manifest, err := d.GetCachedManifest()
250- for _, filename := range FilterFiles.Combined {260+ if err != nil || manifest == nil || len(manifest.Combined.Files) == 0 {
261+ return nil
262+ }
263+ paths := make([]string, 0, len(manifest.Combined.Files))
264+ for _, filename := range manifest.Combined.Files {
251 path := filepath.Join(d.cacheDir, filename)265 path := filepath.Join(d.cacheDir, filename)
252 if _, err := os.Stat(path); err != nil {266 if _, err := os.Stat(path); err != nil {
253 return nil267 return nil
M internal/infrastructure/filtering/manager.go +94 -105
@@ -43,7 +43,7 @@ type Manager struct {
4343 storeDir string
4444 jsonDir string
4545
46- filter *webkit.UserContentFilter
46+ filters []*webkit.UserContentFilter
4747 filterMu sync.RWMutex
4848 status atomic.Value // FilterStatus
4949 enabled bool
@@ -178,19 +178,35 @@ func (m *Manager) loadFromCache(ctx context.Context) bool {
178178 Logger()
179179
180180 m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
181- if !m.store.HasCompiledFilter(ctx, FilterIdentifier) {
181+
182+ // Discover how many compiled parts exist via stored identifiers
183+ identifiers, err := m.store.FetchIdentifiers(ctx)
184+ if err != nil {
182185 return false
183186 }
184-
185- log.Debug().Msg("found compiled filter, loading from cache")
186- filter, err := m.store.Load(ctx, FilterIdentifier)
187- if err != nil || filter == nil {
188- log.Warn().Err(err).Msg("failed to load cached filter, will download")
187+ var partIDs []string
188+ for _, id := range identifiers {
189+ if strings.HasPrefix(id, FilterIdentifierPrefix) {
190+ partIDs = append(partIDs, id)
191+ }
192+ }
193+ if len(partIDs) == 0 {
189194 return false
190195 }
191196
192- version := m.setActiveFilter(filter, "Filters active")
193- log.Info().Str("version", version).Msg("filters loaded from cache")
197+ log.Debug().Int("parts", len(partIDs)).Msg("found compiled filters, loading from cache")
198+ var filters []*webkit.UserContentFilter
199+ for _, id := range partIDs {
200+ filter, loadErr := m.store.Load(ctx, id)
201+ if loadErr != nil || filter == nil {
202+ log.Warn().Err(loadErr).Str("id", id).Msg("failed to load cached filter part, will download")
203+ return false
204+ }
205+ filters = append(filters, filter)
206+ }
207+
208+ version := m.setActiveFilters(filters, "Filters active")
209+ log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters loaded from cache")
194210 return true
195211 }
196212
@@ -213,28 +229,15 @@ func (m *Manager) downloadCompileAndActivate(ctx context.Context) {
213229 return
214230 }
215231
216- m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})
217- mergedPath, err := m.mergeJSONFiles(ctx, paths)
218- if err != nil {
219- log.Error().Err(err).Msg("failed to merge filter files")
220- m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"})
221- return
222- }
223-
224- filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
232+ filters, err := m.compileFilterParts(ctx, paths)
225233 if err != nil {
226234 log.Error().Err(err).Msg("failed to compile filters")
227235 m.handleCompilationFailure()
228236 return
229237 }
230- if filter == nil {
231- log.Error().Msg("filter compilation returned nil filter")
232- m.handleCompilationFailure()
233- return
234- }
235238
236- version := m.setActiveFilter(filter, "Filters active")
237- log.Info().Str("version", version).Msg("filters compiled and active")
239+ version := m.setActiveFilters(filters, "Filters active")
240+ log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters compiled and active")
238241 }
239242
240243 func (m *Manager) downloadFiltersWithProgress(ctx context.Context) ([]string, error) {
@@ -245,6 +248,33 @@ func (m *Manager) downloadFiltersWithProgress(ctx context.Context) ([]string, er
245248 })
246249 }
247250
251+// compileFilterParts compiles each downloaded part file as a separate WebKit filter.
252+// Each part gets a unique identifier (e.g., "ublock-combined-0", "ublock-combined-1").
253+func (m *Manager) compileFilterParts(ctx context.Context, paths []string) ([]*webkit.UserContentFilter, error) {
254+ log := logging.FromContext(ctx).With().
255+ Str("component", "filter-manager").
256+ Logger()
257+
258+ m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})
259+
260+ filters := make([]*webkit.UserContentFilter, 0, len(paths))
261+ for i, path := range paths {
262+ identifier := fmt.Sprintf("%s-%d", FilterIdentifierPrefix, i)
263+ log.Debug().Str("id", identifier).Str("path", path).Msg("compiling filter part")
264+
265+ filter, err := m.store.Compile(ctx, identifier, path)
266+ if err != nil {
267+ return nil, fmt.Errorf("compile %s: %w", identifier, err)
268+ }
269+ if filter == nil {
270+ return nil, fmt.Errorf("compile %s returned nil filter", identifier)
271+ }
272+ filters = append(filters, filter)
273+ }
274+
275+ return filters, nil
276+}
277+
248278 func (m *Manager) handleCompilationFailure() {
249279 if m.hasActiveFilter() {
250280 version := m.getCachedVersion()
@@ -258,9 +288,9 @@ func (m *Manager) handleCompilationFailure() {
258288 m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
259289 }
260290
261-func (m *Manager) setActiveFilter(filter *webkit.UserContentFilter, message string) string {
291+func (m *Manager) setActiveFilters(filters []*webkit.UserContentFilter, message string) string {
262292 m.filterMu.Lock()
263- m.filter = filter
293+ m.filters = filters
264294 m.filterMu.Unlock()
265295
266296 version := m.getCachedVersion()
@@ -272,47 +302,6 @@ func (m *Manager) setActiveFilter(filter *webkit.UserContentFilter, message stri
272302 return version
273303 }
274304
275-// mergeJSONFiles combines multiple JSON rule files into one.
276-// WebKit's UserContentFilterStore expects a single JSON array.
277-func (m *Manager) mergeJSONFiles(ctx context.Context, paths []string) (string, error) {
278- log := logging.FromContext(ctx).With().
279- Str("component", "filter-manager").
280- Logger()
281-
282- var allRules []json.RawMessage
283-
284- for _, path := range paths {
285- log.Debug().Str("path", path).Msg("reading filter file")
286-
287- data, err := os.ReadFile(path)
288- if err != nil {
289- return "", fmt.Errorf("failed to read %s: %w", path, err)
290- }
291-
292- var rules []json.RawMessage
293- if err := json.Unmarshal(data, &rules); err != nil {
294- return "", fmt.Errorf("failed to parse %s: %w", path, err)
295- }
296-
297- allRules = append(allRules, rules...)
298- }
299-
300- log.Debug().Int("total_rules", len(allRules)).Msg("merging filter rules")
301-
302- merged, err := json.Marshal(allRules)
303- if err != nil {
304- return "", fmt.Errorf("failed to marshal merged rules: %w", err)
305- }
306-
307- // Write merged file
308- mergedPath := filepath.Join(m.jsonDir, "merged.json")
309- if err := os.WriteFile(mergedPath, merged, mergedFilePerm); err != nil {
310- return "", fmt.Errorf("failed to write merged file: %w", err)
311- }
312-
313- return mergedPath, nil
314-}
315-
316305 // checkStaleCacheAndUpdate checks if the cache is stale and triggers a background update.
317306 func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
318307 if !m.downloader.IsCacheStale(CacheMaxAge) {
@@ -336,7 +325,7 @@ func (m *Manager) getCachedVersion() string {
336325 return manifest.Version
337326 }
338327
339-// ApplyTo adds the active filter to a WebView's UserContentManager.
328+// ApplyTo adds the active filters to a WebView's UserContentManager.
340329 // Safe to call even if filters are not yet loaded (no-op in that case).
341330 func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) {
342331 log := logging.FromContext(ctx).With().
@@ -348,22 +337,24 @@ func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) {
348337 }
349338
350339 m.filterMu.RLock()
351- filter := m.filter
340+ filters := m.filters
352341 m.filterMu.RUnlock()
353342
354- if filter != nil {
355- ucm.AddFilter(filter)
356- log.Debug().Msg("content filter applied to webview")
343+ if len(filters) > 0 {
344+ for _, filter := range filters {
345+ ucm.AddFilter(filter)
346+ }
347+ log.Debug().Int("parts", len(filters)).Msg("content filters applied to webview")
357348 } else {
358349 log.Debug().Msg("no filter available to apply (filters still loading?)")
359350 }
360351 }
361352
362-// GetFilter returns the currently loaded filter, or nil if none.
363-func (m *Manager) GetFilter() *webkit.UserContentFilter {
353+// GetFilters returns the currently loaded filters, or nil if none.
354+func (m *Manager) GetFilters() []*webkit.UserContentFilter {
364355 m.filterMu.RLock()
365356 defer m.filterMu.RUnlock()
366- return m.filter
357+ return m.filters
367358 }
368359
369360 // CheckForUpdates checks if newer filters are available and downloads them.
@@ -401,34 +392,23 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error {
401392 return fmt.Errorf("%w: validation failed: %w", ErrUpdateSkipped, validateErr)
402393 }
403394
404- // Merge and compile
405- mergedPath, err := m.mergeJSONFiles(ctx, paths)
406- if err != nil {
407- log.Warn().Err(err).Msg("failed to merge updated filters, keeping existing active filter")
408- return fmt.Errorf("%w: merge failed: %w", ErrUpdateSkipped, err)
409- }
410-
411- filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
395+ filters, err := m.compileFilterParts(ctx, paths)
412396 if err != nil {
413397 log.Warn().Err(err).Msg("failed to compile updated filters, keeping existing active filter")
398+ // Restore status if we still have an active filter
399+ if m.hasActiveFilter() {
400+ version := m.getCachedVersion()
401+ m.setStatus(FilterStatus{
402+ State: StateActive,
403+ Message: "Filters active (update skipped)",
404+ Version: version,
405+ })
406+ }
414407 return fmt.Errorf("%w: compile failed: %w", ErrUpdateSkipped, err)
415408 }
416- if filter == nil {
417- log.Warn().Msg("updated filter compilation returned nil filter, keeping existing active filter")
418- return fmt.Errorf("%w: compile returned nil filter", ErrUpdateSkipped)
419- }
420409
421- m.filterMu.Lock()
422- m.filter = filter
423- m.filterMu.Unlock()
424-
425- version := m.getCachedVersion()
426- m.setStatus(FilterStatus{
427- State: StateActive,
428- Message: fmt.Sprintf("Filters updated to %s", version),
429- Version: version,
430- })
431- log.Info().Str("version", version).Msg("filters updated successfully")
410+ version := m.setActiveFilters(filters, "Filters updated")
411+ log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters updated successfully")
432412
433413 return nil
434414 }
@@ -436,7 +416,7 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error {
436416 func (m *Manager) hasActiveFilter() bool {
437417 m.filterMu.RLock()
438418 defer m.filterMu.RUnlock()
439- return m.filter != nil
419+ return len(m.filters) > 0
440420 }
441421
442422 func (m *Manager) validateDownloadedFiles(ctx context.Context, paths []string) error {
@@ -547,10 +527,10 @@ func (m *Manager) Enable(ctx context.Context) {
547527
548528 // Try to load filters if not already loaded
549529 m.filterMu.RLock()
550- hasFilter := m.filter != nil
530+ hasFilters := len(m.filters) > 0
551531 m.filterMu.RUnlock()
552532
553- if !hasFilter {
533+ if !hasFilters {
554534 m.LoadAsync(ctx)
555535 } else {
556536 m.setStatus(FilterStatus{
@@ -584,11 +564,20 @@ func (m *Manager) Clear(ctx context.Context) error {
584564 Logger()
585565
586566 m.filterMu.Lock()
587- m.filter = nil
567+ m.filters = nil
588568 m.filterMu.Unlock()
589569
590- if err := m.store.Remove(ctx, FilterIdentifier); err != nil {
591- log.Warn().Err(err).Msg("failed to remove compiled filter")
570+ // Remove all compiled filter parts
571+ identifiers, err := m.store.FetchIdentifiers(ctx)
572+ if err != nil {
573+ log.Warn().Err(err).Msg("failed to fetch identifiers for cleanup")
574+ }
575+ for _, id := range identifiers {
576+ if strings.HasPrefix(id, FilterIdentifierPrefix) {
577+ if err := m.store.Remove(ctx, id); err != nil {
578+ log.Warn().Err(err).Str("id", id).Msg("failed to remove compiled filter")
579+ }
580+ }
592581 }
593582
594583 if err := m.downloader.ClearCache(); err != nil {
@@ -43,7 +43,7 @@ type Manager struct {
43 storeDir string43 storeDir string
44 jsonDir string44 jsonDir string
45 45
46- filter *webkit.UserContentFilter46+ filters []*webkit.UserContentFilter
47 filterMu sync.RWMutex47 filterMu sync.RWMutex
48 status atomic.Value // FilterStatus48 status atomic.Value // FilterStatus
49 enabled bool49 enabled bool
@@ -178,19 +178,35 @@ func (m *Manager) loadFromCache(ctx context.Context) bool {
178 Logger()178 Logger()
179 179
180 m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})180 m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
181- if !m.store.HasCompiledFilter(ctx, FilterIdentifier) {181+
182+ // Discover how many compiled parts exist via stored identifiers
183+ identifiers, err := m.store.FetchIdentifiers(ctx)
184+ if err != nil {
182 return false185 return false
183 }186 }
184-187+ var partIDs []string
185- log.Debug().Msg("found compiled filter, loading from cache")188+ for _, id := range identifiers {
186- filter, err := m.store.Load(ctx, FilterIdentifier)189+ if strings.HasPrefix(id, FilterIdentifierPrefix) {
187- if err != nil || filter == nil {190+ partIDs = append(partIDs, id)
188- log.Warn().Err(err).Msg("failed to load cached filter, will download")191+ }
192+ }
193+ if len(partIDs) == 0 {
189 return false194 return false
190 }195 }
191 196
192- version := m.setActiveFilter(filter, "Filters active")197+ log.Debug().Int("parts", len(partIDs)).Msg("found compiled filters, loading from cache")
193- log.Info().Str("version", version).Msg("filters loaded from cache")198+ var filters []*webkit.UserContentFilter
199+ for _, id := range partIDs {
200+ filter, loadErr := m.store.Load(ctx, id)
201+ if loadErr != nil || filter == nil {
202+ log.Warn().Err(loadErr).Str("id", id).Msg("failed to load cached filter part, will download")
203+ return false
204+ }
205+ filters = append(filters, filter)
206+ }
207+
208+ version := m.setActiveFilters(filters, "Filters active")
209+ log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters loaded from cache")
194 return true210 return true
195 }211 }
196 212
@@ -213,28 +229,15 @@ func (m *Manager) downloadCompileAndActivate(ctx context.Context) {
213 return229 return
214 }230 }
215 231
216- m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})232+ filters, err := m.compileFilterParts(ctx, paths)
217- mergedPath, err := m.mergeJSONFiles(ctx, paths)
218- if err != nil {
219- log.Error().Err(err).Msg("failed to merge filter files")
220- m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"})
221- return
222- }
223-
224- filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
225 if err != nil {233 if err != nil {
226 log.Error().Err(err).Msg("failed to compile filters")234 log.Error().Err(err).Msg("failed to compile filters")
227 m.handleCompilationFailure()235 m.handleCompilationFailure()
228 return236 return
229 }237 }
230- if filter == nil {
231- log.Error().Msg("filter compilation returned nil filter")
232- m.handleCompilationFailure()
233- return
234- }
235 238
236- version := m.setActiveFilter(filter, "Filters active")239+ version := m.setActiveFilters(filters, "Filters active")
237- log.Info().Str("version", version).Msg("filters compiled and active")240+ log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters compiled and active")
238 }241 }
239 242
240 func (m *Manager) downloadFiltersWithProgress(ctx context.Context) ([]string, error) {243 func (m *Manager) downloadFiltersWithProgress(ctx context.Context) ([]string, error) {
@@ -245,6 +248,33 @@ func (m *Manager) downloadFiltersWithProgress(ctx context.Context) ([]string, er
245 })248 })
246 }249 }
247 250
251+// compileFilterParts compiles each downloaded part file as a separate WebKit filter.
252+// Each part gets a unique identifier (e.g., "ublock-combined-0", "ublock-combined-1").
253+func (m *Manager) compileFilterParts(ctx context.Context, paths []string) ([]*webkit.UserContentFilter, error) {
254+ log := logging.FromContext(ctx).With().
255+ Str("component", "filter-manager").
256+ Logger()
257+
258+ m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})
259+
260+ filters := make([]*webkit.UserContentFilter, 0, len(paths))
261+ for i, path := range paths {
262+ identifier := fmt.Sprintf("%s-%d", FilterIdentifierPrefix, i)
263+ log.Debug().Str("id", identifier).Str("path", path).Msg("compiling filter part")
264+
265+ filter, err := m.store.Compile(ctx, identifier, path)
266+ if err != nil {
267+ return nil, fmt.Errorf("compile %s: %w", identifier, err)
268+ }
269+ if filter == nil {
270+ return nil, fmt.Errorf("compile %s returned nil filter", identifier)
271+ }
272+ filters = append(filters, filter)
273+ }
274+
275+ return filters, nil
276+}
277+
248 func (m *Manager) handleCompilationFailure() {278 func (m *Manager) handleCompilationFailure() {
249 if m.hasActiveFilter() {279 if m.hasActiveFilter() {
250 version := m.getCachedVersion()280 version := m.getCachedVersion()
@@ -258,9 +288,9 @@ func (m *Manager) handleCompilationFailure() {
258 m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})288 m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
259 }289 }
260 290
261-func (m *Manager) setActiveFilter(filter *webkit.UserContentFilter, message string) string {291+func (m *Manager) setActiveFilters(filters []*webkit.UserContentFilter, message string) string {
262 m.filterMu.Lock()292 m.filterMu.Lock()
263- m.filter = filter293+ m.filters = filters
264 m.filterMu.Unlock()294 m.filterMu.Unlock()
265 295
266 version := m.getCachedVersion()296 version := m.getCachedVersion()
@@ -272,47 +302,6 @@ func (m *Manager) setActiveFilter(filter *webkit.UserContentFilter, message stri
272 return version302 return version
273 }303 }
274 304
275-// mergeJSONFiles combines multiple JSON rule files into one.
276-// WebKit's UserContentFilterStore expects a single JSON array.
277-func (m *Manager) mergeJSONFiles(ctx context.Context, paths []string) (string, error) {
278- log := logging.FromContext(ctx).With().
279- Str("component", "filter-manager").
280- Logger()
281-
282- var allRules []json.RawMessage
283-
284- for _, path := range paths {
285- log.Debug().Str("path", path).Msg("reading filter file")
286-
287- data, err := os.ReadFile(path)
288- if err != nil {
289- return "", fmt.Errorf("failed to read %s: %w", path, err)
290- }
291-
292- var rules []json.RawMessage
293- if err := json.Unmarshal(data, &rules); err != nil {
294- return "", fmt.Errorf("failed to parse %s: %w", path, err)
295- }
296-
297- allRules = append(allRules, rules...)
298- }
299-
300- log.Debug().Int("total_rules", len(allRules)).Msg("merging filter rules")
301-
302- merged, err := json.Marshal(allRules)
303- if err != nil {
304- return "", fmt.Errorf("failed to marshal merged rules: %w", err)
305- }
306-
307- // Write merged file
308- mergedPath := filepath.Join(m.jsonDir, "merged.json")
309- if err := os.WriteFile(mergedPath, merged, mergedFilePerm); err != nil {
310- return "", fmt.Errorf("failed to write merged file: %w", err)
311- }
312-
313- return mergedPath, nil
314-}
315-
316 // checkStaleCacheAndUpdate checks if the cache is stale and triggers a background update.305 // checkStaleCacheAndUpdate checks if the cache is stale and triggers a background update.
317 func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {306 func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
318 if !m.downloader.IsCacheStale(CacheMaxAge) {307 if !m.downloader.IsCacheStale(CacheMaxAge) {
@@ -336,7 +325,7 @@ func (m *Manager) getCachedVersion() string {
336 return manifest.Version325 return manifest.Version
337 }326 }
338 327
339-// ApplyTo adds the active filter to a WebView's UserContentManager.328+// ApplyTo adds the active filters to a WebView's UserContentManager.
340 // Safe to call even if filters are not yet loaded (no-op in that case).329 // Safe to call even if filters are not yet loaded (no-op in that case).
341 func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) {330 func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) {
342 log := logging.FromContext(ctx).With().331 log := logging.FromContext(ctx).With().
@@ -348,22 +337,24 @@ func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) {
348 }337 }
349 338
350 m.filterMu.RLock()339 m.filterMu.RLock()
351- filter := m.filter340+ filters := m.filters
352 m.filterMu.RUnlock()341 m.filterMu.RUnlock()
353 342
354- if filter != nil {343+ if len(filters) > 0 {
355- ucm.AddFilter(filter)344+ for _, filter := range filters {
356- log.Debug().Msg("content filter applied to webview")345+ ucm.AddFilter(filter)
346+ }
347+ log.Debug().Int("parts", len(filters)).Msg("content filters applied to webview")
357 } else {348 } else {
358 log.Debug().Msg("no filter available to apply (filters still loading?)")349 log.Debug().Msg("no filter available to apply (filters still loading?)")
359 }350 }
360 }351 }
361 352
362-// GetFilter returns the currently loaded filter, or nil if none.353+// GetFilters returns the currently loaded filters, or nil if none.
363-func (m *Manager) GetFilter() *webkit.UserContentFilter {354+func (m *Manager) GetFilters() []*webkit.UserContentFilter {
364 m.filterMu.RLock()355 m.filterMu.RLock()
365 defer m.filterMu.RUnlock()356 defer m.filterMu.RUnlock()
366- return m.filter357+ return m.filters
367 }358 }
368 359
369 // CheckForUpdates checks if newer filters are available and downloads them.360 // CheckForUpdates checks if newer filters are available and downloads them.
@@ -401,34 +392,23 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error {
401 return fmt.Errorf("%w: validation failed: %w", ErrUpdateSkipped, validateErr)392 return fmt.Errorf("%w: validation failed: %w", ErrUpdateSkipped, validateErr)
402 }393 }
403 394
404- // Merge and compile395+ filters, err := m.compileFilterParts(ctx, paths)
405- mergedPath, err := m.mergeJSONFiles(ctx, paths)
406- if err != nil {
407- log.Warn().Err(err).Msg("failed to merge updated filters, keeping existing active filter")
408- return fmt.Errorf("%w: merge failed: %w", ErrUpdateSkipped, err)
409- }
410-
411- filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
412 if err != nil {396 if err != nil {
413 log.Warn().Err(err).Msg("failed to compile updated filters, keeping existing active filter")397 log.Warn().Err(err).Msg("failed to compile updated filters, keeping existing active filter")
398+ // Restore status if we still have an active filter
399+ if m.hasActiveFilter() {
400+ version := m.getCachedVersion()
401+ m.setStatus(FilterStatus{
402+ State: StateActive,
403+ Message: "Filters active (update skipped)",
404+ Version: version,
405+ })
406+ }
414 return fmt.Errorf("%w: compile failed: %w", ErrUpdateSkipped, err)407 return fmt.Errorf("%w: compile failed: %w", ErrUpdateSkipped, err)
415 }408 }
416- if filter == nil {
417- log.Warn().Msg("updated filter compilation returned nil filter, keeping existing active filter")
418- return fmt.Errorf("%w: compile returned nil filter", ErrUpdateSkipped)
419- }
420 409
421- m.filterMu.Lock()410+ version := m.setActiveFilters(filters, "Filters updated")
422- m.filter = filter411+ log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters updated successfully")
423- m.filterMu.Unlock()
424-
425- version := m.getCachedVersion()
426- m.setStatus(FilterStatus{
427- State: StateActive,
428- Message: fmt.Sprintf("Filters updated to %s", version),
429- Version: version,
430- })
431- log.Info().Str("version", version).Msg("filters updated successfully")
432 412
433 return nil413 return nil
434 }414 }
@@ -436,7 +416,7 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error {
436 func (m *Manager) hasActiveFilter() bool {416 func (m *Manager) hasActiveFilter() bool {
437 m.filterMu.RLock()417 m.filterMu.RLock()
438 defer m.filterMu.RUnlock()418 defer m.filterMu.RUnlock()
439- return m.filter != nil419+ return len(m.filters) > 0
440 }420 }
441 421
442 func (m *Manager) validateDownloadedFiles(ctx context.Context, paths []string) error {422 func (m *Manager) validateDownloadedFiles(ctx context.Context, paths []string) error {
@@ -547,10 +527,10 @@ func (m *Manager) Enable(ctx context.Context) {
547 527
548 // Try to load filters if not already loaded528 // Try to load filters if not already loaded
549 m.filterMu.RLock()529 m.filterMu.RLock()
550- hasFilter := m.filter != nil530+ hasFilters := len(m.filters) > 0
551 m.filterMu.RUnlock()531 m.filterMu.RUnlock()
552 532
553- if !hasFilter {533+ if !hasFilters {
554 m.LoadAsync(ctx)534 m.LoadAsync(ctx)
555 } else {535 } else {
556 m.setStatus(FilterStatus{536 m.setStatus(FilterStatus{
@@ -584,11 +564,20 @@ func (m *Manager) Clear(ctx context.Context) error {
584 Logger()564 Logger()
585 565
586 m.filterMu.Lock()566 m.filterMu.Lock()
587- m.filter = nil567+ m.filters = nil
588 m.filterMu.Unlock()568 m.filterMu.Unlock()
589 569
590- if err := m.store.Remove(ctx, FilterIdentifier); err != nil {570+ // Remove all compiled filter parts
591- log.Warn().Err(err).Msg("failed to remove compiled filter")571+ identifiers, err := m.store.FetchIdentifiers(ctx)
572+ if err != nil {
573+ log.Warn().Err(err).Msg("failed to fetch identifiers for cleanup")
574+ }
575+ for _, id := range identifiers {
576+ if strings.HasPrefix(id, FilterIdentifierPrefix) {
577+ if err := m.store.Remove(ctx, id); err != nil {
578+ log.Warn().Err(err).Str("id", id).Msg("failed to remove compiled filter")
579+ }
580+ }
592 }581 }
593 582
594 if err := m.downloader.ClearCache(); err != nil {583 if err := m.downloader.ClearCache(); err != nil {
M internal/infrastructure/filtering/manager_test.go +35 -25
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "os"
77 "path/filepath"
8+ "strings"
89 "sync"
910 "testing"
1011 "time"
@@ -39,14 +40,14 @@ func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) {
3940 mockStore := mocks.NewMockFilterStore(t)
4041 mockDownloader := mocks.NewMockFilterDownloader(t)
4142
42- // Setup expectations: filter exists in store but returns nil (can't create real filter in tests)
43- // This verifies the cache check path is taken
43+ // Setup expectations: filter identifier exists in store but Load returns nil
44+ // This verifies the cache check path is taken, then falls back to download
4445 mockStore.EXPECT().
45- HasCompiledFilter(mock.Anything, filtering.FilterIdentifier).
46- Return(true)
46+ FetchIdentifiers(mock.Anything).
47+ Return([]string{"ublock-combined-0"}, nil).Once()
4748
4849 mockStore.EXPECT().
49- Load(mock.Anything, filtering.FilterIdentifier).
50+ Load(mock.Anything, "ublock-combined-0").
5051 Return(nil, nil) // nil filter triggers fallback to download
5152
5253 // Fallback: download will be triggered since Load returned nil filter
@@ -55,7 +56,7 @@ func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) {
5556 Return([]string{jsonFile}, nil)
5657
5758 mockStore.EXPECT().
58- Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).
59+ Compile(mock.Anything, mock.MatchedBy(func(s string) bool { return strings.HasPrefix(s, filtering.FilterIdentifierPrefix) }), mock.Anything).
5960 Return(&webkit.UserContentFilter{}, nil)
6061
6162 mockDownloader.EXPECT().
@@ -135,8 +136,8 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) {
135136
136137 // Setup expectations: no filter in store
137138 mockStore.EXPECT().
138- HasCompiledFilter(mock.Anything, filtering.FilterIdentifier).
139- Return(false)
139+ FetchIdentifiers(mock.Anything).
140+ Return([]string{}, nil)
140141
141142 // Download should be triggered
142143 mockDownloader.EXPECT().
@@ -145,7 +146,7 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) {
145146
146147 // Compile should be called
147148 mockStore.EXPECT().
148- Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).
149+ Compile(mock.Anything, "ublock-combined-0", mock.Anything).
149150 Return(&webkit.UserContentFilter{}, nil)
150151
151152 // GetCachedManifest for version
@@ -242,7 +243,7 @@ func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T)
242243
243244 // Compile called
244245 mockStore.EXPECT().
245- Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).
246+ Compile(mock.Anything, "ublock-combined-0", mock.Anything).
246247 Return(&webkit.UserContentFilter{}, nil)
247248
248249 // GetCachedManifest for version
@@ -363,9 +364,15 @@ func TestManager_Clear_RemovesFilterAndCache(t *testing.T) {
363364 mockStore := mocks.NewMockFilterStore(t)
364365 mockDownloader := mocks.NewMockFilterDownloader(t)
365366
366- // Remove should be called
367+ // FetchIdentifiers + Remove should be called for each part
367368 mockStore.EXPECT().
368- Remove(mock.Anything, filtering.FilterIdentifier).
369+ FetchIdentifiers(mock.Anything).
370+ Return([]string{"ublock-combined-0", "ublock-combined-1"}, nil)
371+ mockStore.EXPECT().
372+ Remove(mock.Anything, "ublock-combined-0").
373+ Return(nil)
374+ mockStore.EXPECT().
375+ Remove(mock.Anything, "ublock-combined-1").
369376 Return(nil)
370377
371378 // ClearCache should be called
@@ -443,16 +450,16 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) {
443450
444451 // Setup for a load with fallback to download (since we can't return real filter)
445452 mockStore.EXPECT().
446- HasCompiledFilter(mock.Anything, filtering.FilterIdentifier).
447- Return(true)
453+ FetchIdentifiers(mock.Anything).
454+ Return([]string{"ublock-combined-0"}, nil).Once()
448455 mockStore.EXPECT().
449- Load(mock.Anything, filtering.FilterIdentifier).
456+ Load(mock.Anything, "ublock-combined-0").
450457 Return(nil, nil) // nil triggers download fallback
451458 mockDownloader.EXPECT().
452459 DownloadFilters(mock.Anything, mock.Anything).
453460 Return([]string{jsonFile}, nil)
454461 mockStore.EXPECT().
455- Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).
462+ Compile(mock.Anything, "ublock-combined-0", mock.Anything).
456463 Return(&webkit.UserContentFilter{}, nil)
457464 mockDownloader.EXPECT().
458465 GetCachedManifest().
@@ -510,8 +517,8 @@ func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) {
510517 mockDownloader := mocks.NewMockFilterDownloader(t)
511518
512519 mockStore.EXPECT().
513- HasCompiledFilter(mock.Anything, filtering.FilterIdentifier).
514- Return(false)
520+ FetchIdentifiers(mock.Anything).
521+ Return([]string{}, nil)
515522
516523 mockDownloader.EXPECT().
517524 DownloadFilters(mock.Anything, mock.Anything).
@@ -561,19 +568,21 @@ func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) {
561568
562569 activeFilter := &webkit.UserContentFilter{}
563570
571+ // loadFromCache succeeds — returns activeFilter
564572 mockStore.EXPECT().
565- HasCompiledFilter(mock.Anything, filtering.FilterIdentifier).
566- Return(true).Once()
573+ FetchIdentifiers(mock.Anything).
574+ Return([]string{"ublock-combined-0"}, nil).Once()
567575 mockStore.EXPECT().
568- Load(mock.Anything, filtering.FilterIdentifier).
576+ Load(mock.Anything, "ublock-combined-0").
569577 Return(activeFilter, nil).Once()
570578 mockDownloader.EXPECT().
571579 GetCachedManifest().
572- Return(&filtering.Manifest{Version: "2025.12.19"}, nil).Once()
580+ Return(&filtering.Manifest{Version: "2025.12.19"}, nil)
573581 mockDownloader.EXPECT().
574582 IsCacheStale(filtering.CacheMaxAge).
575583 Return(false).Once()
576584
585+ // CheckForUpdates: compile fails, should keep active filter
577586 mockDownloader.EXPECT().
578587 NeedsUpdate(mock.Anything).
579588 Return(true, nil).Once()
@@ -581,7 +590,7 @@ func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) {
581590 DownloadFilters(mock.Anything, mock.Anything).
582591 Return([]string{jsonFile}, nil).Once()
583592 mockStore.EXPECT().
584- Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).
593+ Compile(mock.Anything, "ublock-combined-0", mock.Anything).
585594 Return(nil, errors.New("compile failed")).Once()
586595
587596 mgr, err := filtering.NewManager(filtering.ManagerConfig{
@@ -599,11 +608,12 @@ func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) {
599608 mgr.LoadAsync(ctx)
600609
601610 require.Eventually(t, func() bool {
602- return mgr.GetFilter() != nil && mgr.Status().State == filtering.StateActive
611+ return len(mgr.GetFilters()) > 0 && mgr.Status().State == filtering.StateActive
603612 }, 3*time.Second, 50*time.Millisecond)
604613
605614 err = mgr.CheckForUpdates(ctx)
606615 require.ErrorIs(t, err, filtering.ErrUpdateSkipped)
607- assert.Same(t, activeFilter, mgr.GetFilter())
616+ require.Len(t, mgr.GetFilters(), 1)
617+ assert.Same(t, activeFilter, mgr.GetFilters()[0])
608618 assert.Equal(t, filtering.StateActive, mgr.Status().State)
609619 }
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8+ "strings"
8 "sync"9 "sync"
9 "testing"10 "testing"
10 "time"11 "time"
@@ -39,14 +40,14 @@ func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) {
39 mockStore := mocks.NewMockFilterStore(t)40 mockStore := mocks.NewMockFilterStore(t)
40 mockDownloader := mocks.NewMockFilterDownloader(t)41 mockDownloader := mocks.NewMockFilterDownloader(t)
41 42
42- // Setup expectations: filter exists in store but returns nil (can't create real filter in tests)43+ // Setup expectations: filter identifier exists in store but Load returns nil
43- // This verifies the cache check path is taken44+ // This verifies the cache check path is taken, then falls back to download
44 mockStore.EXPECT().45 mockStore.EXPECT().
45- HasCompiledFilter(mock.Anything, filtering.FilterIdentifier).46+ FetchIdentifiers(mock.Anything).
46- Return(true)47+ Return([]string{"ublock-combined-0"}, nil).Once()
47 48
48 mockStore.EXPECT().49 mockStore.EXPECT().
49- Load(mock.Anything, filtering.FilterIdentifier).50+ Load(mock.Anything, "ublock-combined-0").
50 Return(nil, nil) // nil filter triggers fallback to download51 Return(nil, nil) // nil filter triggers fallback to download
51 52
52 // Fallback: download will be triggered since Load returned nil filter53 // Fallback: download will be triggered since Load returned nil filter
@@ -55,7 +56,7 @@ func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) {
55 Return([]string{jsonFile}, nil)56 Return([]string{jsonFile}, nil)
56 57
57 mockStore.EXPECT().58 mockStore.EXPECT().
58- Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).59+ Compile(mock.Anything, mock.MatchedBy(func(s string) bool { return strings.HasPrefix(s, filtering.FilterIdentifierPrefix) }), mock.Anything).
59 Return(&webkit.UserContentFilter{}, nil)60 Return(&webkit.UserContentFilter{}, nil)
60 61
61 mockDownloader.EXPECT().62 mockDownloader.EXPECT().
@@ -135,8 +136,8 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) {
135 136
136 // Setup expectations: no filter in store137 // Setup expectations: no filter in store
137 mockStore.EXPECT().138 mockStore.EXPECT().
138- HasCompiledFilter(mock.Anything, filtering.FilterIdentifier).139+ FetchIdentifiers(mock.Anything).
139- Return(false)140+ Return([]string{}, nil)
140 141
141 // Download should be triggered142 // Download should be triggered
142 mockDownloader.EXPECT().143 mockDownloader.EXPECT().
@@ -145,7 +146,7 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) {
145 146
146 // Compile should be called147 // Compile should be called
147 mockStore.EXPECT().148 mockStore.EXPECT().
148- Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).149+ Compile(mock.Anything, "ublock-combined-0", mock.Anything).
149 Return(&webkit.UserContentFilter{}, nil)150 Return(&webkit.UserContentFilter{}, nil)
150 151
151 // GetCachedManifest for version152 // GetCachedManifest for version
@@ -242,7 +243,7 @@ func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T)
242 243
243 // Compile called244 // Compile called
244 mockStore.EXPECT().245 mockStore.EXPECT().
245- Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).246+ Compile(mock.Anything, "ublock-combined-0", mock.Anything).
246 Return(&webkit.UserContentFilter{}, nil)247 Return(&webkit.UserContentFilter{}, nil)
247 248
248 // GetCachedManifest for version249 // GetCachedManifest for version
@@ -363,9 +364,15 @@ func TestManager_Clear_RemovesFilterAndCache(t *testing.T) {
363 mockStore := mocks.NewMockFilterStore(t)364 mockStore := mocks.NewMockFilterStore(t)
364 mockDownloader := mocks.NewMockFilterDownloader(t)365 mockDownloader := mocks.NewMockFilterDownloader(t)
365 366
366- // Remove should be called367+ // FetchIdentifiers + Remove should be called for each part
367 mockStore.EXPECT().368 mockStore.EXPECT().
368- Remove(mock.Anything, filtering.FilterIdentifier).369+ FetchIdentifiers(mock.Anything).
370+ Return([]string{"ublock-combined-0", "ublock-combined-1"}, nil)
371+ mockStore.EXPECT().
372+ Remove(mock.Anything, "ublock-combined-0").
373+ Return(nil)
374+ mockStore.EXPECT().
375+ Remove(mock.Anything, "ublock-combined-1").
369 Return(nil)376 Return(nil)
370 377
371 // ClearCache should be called378 // ClearCache should be called
@@ -443,16 +450,16 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) {
443 450
444 // Setup for a load with fallback to download (since we can't return real filter)451 // Setup for a load with fallback to download (since we can't return real filter)
445 mockStore.EXPECT().452 mockStore.EXPECT().
446- HasCompiledFilter(mock.Anything, filtering.FilterIdentifier).453+ FetchIdentifiers(mock.Anything).
447- Return(true)454+ Return([]string{"ublock-combined-0"}, nil).Once()
448 mockStore.EXPECT().455 mockStore.EXPECT().
449- Load(mock.Anything, filtering.FilterIdentifier).456+ Load(mock.Anything, "ublock-combined-0").
450 Return(nil, nil) // nil triggers download fallback457 Return(nil, nil) // nil triggers download fallback
451 mockDownloader.EXPECT().458 mockDownloader.EXPECT().
452 DownloadFilters(mock.Anything, mock.Anything).459 DownloadFilters(mock.Anything, mock.Anything).
453 Return([]string{jsonFile}, nil)460 Return([]string{jsonFile}, nil)
454 mockStore.EXPECT().461 mockStore.EXPECT().
455- Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).462+ Compile(mock.Anything, "ublock-combined-0", mock.Anything).
456 Return(&webkit.UserContentFilter{}, nil)463 Return(&webkit.UserContentFilter{}, nil)
457 mockDownloader.EXPECT().464 mockDownloader.EXPECT().
458 GetCachedManifest().465 GetCachedManifest().
@@ -510,8 +517,8 @@ func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) {
510 mockDownloader := mocks.NewMockFilterDownloader(t)517 mockDownloader := mocks.NewMockFilterDownloader(t)
511 518
512 mockStore.EXPECT().519 mockStore.EXPECT().
513- HasCompiledFilter(mock.Anything, filtering.FilterIdentifier).520+ FetchIdentifiers(mock.Anything).
514- Return(false)521+ Return([]string{}, nil)
515 522
516 mockDownloader.EXPECT().523 mockDownloader.EXPECT().
517 DownloadFilters(mock.Anything, mock.Anything).524 DownloadFilters(mock.Anything, mock.Anything).
@@ -561,19 +568,21 @@ func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) {
561 568
562 activeFilter := &webkit.UserContentFilter{}569 activeFilter := &webkit.UserContentFilter{}
563 570
571+ // loadFromCache succeeds — returns activeFilter
564 mockStore.EXPECT().572 mockStore.EXPECT().
565- HasCompiledFilter(mock.Anything, filtering.FilterIdentifier).573+ FetchIdentifiers(mock.Anything).
566- Return(true).Once()574+ Return([]string{"ublock-combined-0"}, nil).Once()
567 mockStore.EXPECT().575 mockStore.EXPECT().
568- Load(mock.Anything, filtering.FilterIdentifier).576+ Load(mock.Anything, "ublock-combined-0").
569 Return(activeFilter, nil).Once()577 Return(activeFilter, nil).Once()
570 mockDownloader.EXPECT().578 mockDownloader.EXPECT().
571 GetCachedManifest().579 GetCachedManifest().
572- Return(&filtering.Manifest{Version: "2025.12.19"}, nil).Once()580+ Return(&filtering.Manifest{Version: "2025.12.19"}, nil)
573 mockDownloader.EXPECT().581 mockDownloader.EXPECT().
574 IsCacheStale(filtering.CacheMaxAge).582 IsCacheStale(filtering.CacheMaxAge).
575 Return(false).Once()583 Return(false).Once()
576 584
585+ // CheckForUpdates: compile fails, should keep active filter
577 mockDownloader.EXPECT().586 mockDownloader.EXPECT().
578 NeedsUpdate(mock.Anything).587 NeedsUpdate(mock.Anything).
579 Return(true, nil).Once()588 Return(true, nil).Once()
@@ -581,7 +590,7 @@ func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) {
581 DownloadFilters(mock.Anything, mock.Anything).590 DownloadFilters(mock.Anything, mock.Anything).
582 Return([]string{jsonFile}, nil).Once()591 Return([]string{jsonFile}, nil).Once()
583 mockStore.EXPECT().592 mockStore.EXPECT().
584- Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything).593+ Compile(mock.Anything, "ublock-combined-0", mock.Anything).
585 Return(nil, errors.New("compile failed")).Once()594 Return(nil, errors.New("compile failed")).Once()
586 595
587 mgr, err := filtering.NewManager(filtering.ManagerConfig{596 mgr, err := filtering.NewManager(filtering.ManagerConfig{
@@ -599,11 +608,12 @@ func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) {
599 mgr.LoadAsync(ctx)608 mgr.LoadAsync(ctx)
600 609
601 require.Eventually(t, func() bool {610 require.Eventually(t, func() bool {
602- return mgr.GetFilter() != nil && mgr.Status().State == filtering.StateActive611+ return len(mgr.GetFilters()) > 0 && mgr.Status().State == filtering.StateActive
603 }, 3*time.Second, 50*time.Millisecond)612 }, 3*time.Second, 50*time.Millisecond)
604 613
605 err = mgr.CheckForUpdates(ctx)614 err = mgr.CheckForUpdates(ctx)
606 require.ErrorIs(t, err, filtering.ErrUpdateSkipped)615 require.ErrorIs(t, err, filtering.ErrUpdateSkipped)
607- assert.Same(t, activeFilter, mgr.GetFilter())616+ require.Len(t, mgr.GetFilters(), 1)
617+ assert.Same(t, activeFilter, mgr.GetFilters()[0])
608 assert.Equal(t, filtering.StateActive, mgr.Status().State)618 assert.Equal(t, filtering.StateActive, mgr.Status().State)
609 }619 }
M internal/infrastructure/filtering/types.go +4 -9
@@ -48,21 +48,16 @@ type CombinedInfo struct {
4848 Files []string `json:"files"`
4949 }
5050
51-// FilterFiles defines the files to download from GitHub releases.
51+// FilterFiles defines the well-known file names for GitHub releases.
5252 var FilterFiles = struct {
5353 Manifest string
54- Combined []string
5554 }{
5655 Manifest: "manifest.json",
57- Combined: []string{
58- "combined-part1.json",
59- "combined-part2.json",
60- "combined-part3.json",
61- },
6256 }
6357
64-// FilterIdentifier is the identifier used when storing/loading compiled filters.
65-const FilterIdentifier = "ublock-combined"
58+// FilterIdentifierPrefix is the prefix used when storing/loading compiled filters.
59+// Each part file gets its own identifier: "ublock-combined-0", "ublock-combined-1", etc.
60+const FilterIdentifierPrefix = "ublock-combined"
6661
6762 // GitHubReleaseURL is the base URL for downloading filter files.
6863 const GitHubReleaseURL = "https://github.com/bnema/ublock-webkit-filters/releases/latest/download"
@@ -48,21 +48,16 @@ type CombinedInfo struct {
48 Files []string `json:"files"`48 Files []string `json:"files"`
49 }49 }
50 50
51-// FilterFiles defines the files to download from GitHub releases.51+// FilterFiles defines the well-known file names for GitHub releases.
52 var FilterFiles = struct {52 var FilterFiles = struct {
53 Manifest string53 Manifest string
54- Combined []string
55 }{54 }{
56 Manifest: "manifest.json",55 Manifest: "manifest.json",
57- Combined: []string{
58- "combined-part1.json",
59- "combined-part2.json",
60- "combined-part3.json",
61- },
62 }56 }
63 57
64-// FilterIdentifier is the identifier used when storing/loading compiled filters.58+// FilterIdentifierPrefix is the prefix used when storing/loading compiled filters.
65-const FilterIdentifier = "ublock-combined"59+// Each part file gets its own identifier: "ublock-combined-0", "ublock-combined-1", etc.
60+const FilterIdentifierPrefix = "ublock-combined"
66 61
67 // GitHubReleaseURL is the base URL for downloading filter files.62 // GitHubReleaseURL is the base URL for downloading filter files.
68 const GitHubReleaseURL = "https://github.com/bnema/ublock-webkit-filters/releases/latest/download"63 const GitHubReleaseURL = "https://github.com/bnema/ublock-webkit-filters/releases/latest/download"
M internal/infrastructure/webkit/webview.go +16 -0
@@ -418,6 +418,7 @@ func (wv *WebView) connectSignals() {
418418 wv.connectURISignal()
419419 wv.connectFaviconSignal()
420420 wv.connectProgressSignal()
421+ wv.connectLoadFailedSignal()
421422 wv.connectDecidePolicySignal()
422423 wv.connectEnterFullscreenSignal()
423424 wv.connectLeaveFullscreenSignal()
@@ -464,6 +465,21 @@ func (wv *WebView) connectLoadChangedSignal() {
464465 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
465466 }
466467
468+func (wv *WebView) connectLoadFailedSignal() {
469+ loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, _ uintptr) bool {
470+ // Note: error details are a raw uintptr (GError*) — see bnema/puregotk#6
471+ // for tracking proper *glib.Error conversion in signal callbacks.
472+ wv.logger.Warn().
473+ Str("component", "webview").
474+ Str("uri", failingURI).
475+ Int("load_event", int(event)).
476+ Msg("load failed")
477+ return false
478+ }
479+ sigID := wv.inner.ConnectLoadFailed(&loadFailedCb)
480+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
481+}
482+
467483 func (wv *WebView) connectCloseSignal() {
468484 closeCb := func(_ webkit.WebView) {
469485 if wv.OnClose != nil {
@@ -418,6 +418,7 @@ func (wv *WebView) connectSignals() {
418 wv.connectURISignal()418 wv.connectURISignal()
419 wv.connectFaviconSignal()419 wv.connectFaviconSignal()
420 wv.connectProgressSignal()420 wv.connectProgressSignal()
421+ wv.connectLoadFailedSignal()
421 wv.connectDecidePolicySignal()422 wv.connectDecidePolicySignal()
422 wv.connectEnterFullscreenSignal()423 wv.connectEnterFullscreenSignal()
423 wv.connectLeaveFullscreenSignal()424 wv.connectLeaveFullscreenSignal()
@@ -464,6 +465,21 @@ func (wv *WebView) connectLoadChangedSignal() {
464 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))465 wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
465 }466 }
466 467
468+func (wv *WebView) connectLoadFailedSignal() {
469+ loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, _ uintptr) bool {
470+ // Note: error details are a raw uintptr (GError*) — see bnema/puregotk#6
471+ // for tracking proper *glib.Error conversion in signal callbacks.
472+ wv.logger.Warn().
473+ Str("component", "webview").
474+ Str("uri", failingURI).
475+ Int("load_event", int(event)).
476+ Msg("load failed")
477+ return false
478+ }
479+ sigID := wv.inner.ConnectLoadFailed(&loadFailedCb)
480+ wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
481+}
482+
467 func (wv *WebView) connectCloseSignal() {483 func (wv *WebView) connectCloseSignal() {
468 closeCb := func(_ webkit.WebView) {484 closeCb := func(_ webkit.WebView) {
469 if wv.OnClose != nil {485 if wv.OnClose != nil {

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close