Merge pull request #205 from bnema/fix/dynamic-filter-files
fix: dynamic filter file discovery and per-part compilation
5 files · +179 -155
M internal/infrastructure/filtering/downloader.go +30 -16
| @@ -106,8 +106,8 @@ type DownloadProgress struct { | |||
| 106 | 106 | BytesTotal int64 | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 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. | |
| 111 | 111 | func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(DownloadProgress)) ([]string, error) { | |
| 112 | 112 | log := logging.FromContext(ctx).With(). | |
| 113 | 113 | Str("component", "filter-downloader"). | |
| @@ -118,7 +118,22 @@ func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(Downlo | |||
| 118 | 118 | return nil, fmt.Errorf("failed to create cache dir: %w", err) | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 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 | |
| 122 | 137 | paths := make([]string, 0, len(files)) | |
| 123 | 138 | var totalBytes int64 | |
| 124 | 139 | ||
| @@ -149,10 +164,9 @@ func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(Downlo | |||
| 149 | 164 | log.Debug().Str("file", filename).Int64("bytes", bytesDownloaded).Msg("downloaded filter file") | |
| 150 | 165 | } | |
| 151 | 166 | ||
| 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 { | |
| 154 | 169 | log.Warn().Err(err).Msg("failed to cache manifest") | |
| 155 | - | // Non-fatal, continue | |
| 156 | 170 | } | |
| 157 | 171 | ||
| 158 | 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 | 237 | return nil | |
| 224 | 238 | } | |
| 225 | 239 | ||
| 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 { | |
| 233 | 242 | data, err := json.MarshalIndent(manifest, "", " ") | |
| 234 | 243 | if err != nil { | |
| 235 | 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 | 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 | 258 | 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 { | |
| 251 | 265 | path := filepath.Join(d.cacheDir, filename) | |
| 252 | 266 | if _, err := os.Stat(path); err != nil { | |
| 253 | 267 | return nil | |
| @@ -106,8 +106,8 @@ type DownloadProgress struct { | |||||
| 106 | BytesTotal int64 | 106 | 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.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 | |||
| 122 | paths := make([]string, 0, len(files)) | 137 | paths := make([]string, 0, len(files)) | ||
| 123 | var totalBytes int64 | 138 | 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 manifest | 167 | + | // 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 nil | 237 | 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 nil | 267 | return nil | ||
M internal/infrastructure/filtering/manager.go +94 -105
| @@ -43,7 +43,7 @@ type Manager struct { | |||
| 43 | 43 | storeDir string | |
| 44 | 44 | jsonDir string | |
| 45 | 45 | ||
| 46 | - | filter *webkit.UserContentFilter | |
| 46 | + | filters []*webkit.UserContentFilter | |
| 47 | 47 | filterMu sync.RWMutex | |
| 48 | 48 | status atomic.Value // FilterStatus | |
| 49 | 49 | enabled bool | |
| @@ -178,19 +178,35 @@ func (m *Manager) loadFromCache(ctx context.Context) bool { | |||
| 178 | 178 | Logger() | |
| 179 | 179 | ||
| 180 | 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 | 185 | return false | |
| 183 | 186 | } | |
| 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 { | |
| 189 | 194 | return false | |
| 190 | 195 | } | |
| 191 | 196 | ||
| 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") | |
| 194 | 210 | return true | |
| 195 | 211 | } | |
| 196 | 212 | ||
| @@ -213,28 +229,15 @@ func (m *Manager) downloadCompileAndActivate(ctx context.Context) { | |||
| 213 | 229 | return | |
| 214 | 230 | } | |
| 215 | 231 | ||
| 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) | |
| 225 | 233 | if err != nil { | |
| 226 | 234 | log.Error().Err(err).Msg("failed to compile filters") | |
| 227 | 235 | m.handleCompilationFailure() | |
| 228 | 236 | 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") | |
| 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") | |
| 238 | 241 | } | |
| 239 | 242 | ||
| 240 | 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 | 278 | func (m *Manager) handleCompilationFailure() { | |
| 249 | 279 | if m.hasActiveFilter() { | |
| 250 | 280 | version := m.getCachedVersion() | |
| @@ -258,9 +288,9 @@ func (m *Manager) handleCompilationFailure() { | |||
| 258 | 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 | 292 | m.filterMu.Lock() | |
| 263 | - | m.filter = filter | |
| 293 | + | m.filters = filters | |
| 264 | 294 | m.filterMu.Unlock() | |
| 265 | 295 | ||
| 266 | 296 | version := m.getCachedVersion() | |
| @@ -272,47 +302,6 @@ func (m *Manager) setActiveFilter(filter *webkit.UserContentFilter, message stri | |||
| 272 | 302 | 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 | 305 | // checkStaleCacheAndUpdate checks if the cache is stale and triggers a background update. | |
| 317 | 306 | func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) { | |
| 318 | 307 | if !m.downloader.IsCacheStale(CacheMaxAge) { | |
| @@ -336,7 +325,7 @@ func (m *Manager) getCachedVersion() string { | |||
| 336 | 325 | 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 | 329 | // Safe to call even if filters are not yet loaded (no-op in that case). | |
| 341 | 330 | func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) { | |
| 342 | 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 | 339 | m.filterMu.RLock() | |
| 351 | - | filter := m.filter | |
| 340 | + | filters := m.filters | |
| 352 | 341 | m.filterMu.RUnlock() | |
| 353 | 342 | ||
| 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") | |
| 357 | 348 | } else { | |
| 358 | 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. | |
| 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 { | |
| 364 | 355 | m.filterMu.RLock() | |
| 365 | 356 | defer m.filterMu.RUnlock() | |
| 366 | - | return m.filter | |
| 357 | + | return m.filters | |
| 367 | 358 | } | |
| 368 | 359 | ||
| 369 | 360 | // CheckForUpdates checks if newer filters are available and downloads them. | |
| @@ -401,34 +392,23 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error { | |||
| 401 | 392 | return fmt.Errorf("%w: validation failed: %w", ErrUpdateSkipped, validateErr) | |
| 402 | 393 | } | |
| 403 | 394 | ||
| 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) | |
| 412 | 396 | if err != nil { | |
| 413 | 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 | 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() | |
| 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") | |
| 432 | 412 | ||
| 433 | 413 | return nil | |
| 434 | 414 | } | |
| @@ -436,7 +416,7 @@ func (m *Manager) CheckForUpdates(ctx context.Context) error { | |||
| 436 | 416 | func (m *Manager) hasActiveFilter() bool { | |
| 437 | 417 | m.filterMu.RLock() | |
| 438 | 418 | defer m.filterMu.RUnlock() | |
| 439 | - | return m.filter != nil | |
| 419 | + | return len(m.filters) > 0 | |
| 440 | 420 | } | |
| 441 | 421 | ||
| 442 | 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 | 528 | // Try to load filters if not already loaded | |
| 549 | 529 | m.filterMu.RLock() | |
| 550 | - | hasFilter := m.filter != nil | |
| 530 | + | hasFilters := len(m.filters) > 0 | |
| 551 | 531 | m.filterMu.RUnlock() | |
| 552 | 532 | ||
| 553 | - | if !hasFilter { | |
| 533 | + | if !hasFilters { | |
| 554 | 534 | m.LoadAsync(ctx) | |
| 555 | 535 | } else { | |
| 556 | 536 | m.setStatus(FilterStatus{ | |
| @@ -584,11 +564,20 @@ func (m *Manager) Clear(ctx context.Context) error { | |||
| 584 | 564 | Logger() | |
| 585 | 565 | ||
| 586 | 566 | m.filterMu.Lock() | |
| 587 | - | m.filter = nil | |
| 567 | + | m.filters = nil | |
| 588 | 568 | m.filterMu.Unlock() | |
| 589 | 569 | ||
| 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 | + | } | |
| 592 | 581 | } | |
| 593 | 582 | ||
| 594 | 583 | if err := m.downloader.ClearCache(); err != nil { | |
| @@ -43,7 +43,7 @@ type Manager struct { | |||||
| 43 | storeDir string | 43 | storeDir string | ||
| 44 | jsonDir string | 44 | jsonDir string | ||
| 45 | 45 | ||||
| 46 | - | filter *webkit.UserContentFilter | 46 | + | filters []*webkit.UserContentFilter |
| 47 | filterMu sync.RWMutex | 47 | filterMu sync.RWMutex | ||
| 48 | status atomic.Value // FilterStatus | 48 | status atomic.Value // FilterStatus | ||
| 49 | enabled bool | 49 | 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 false | 185 | 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 false | 194 | 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 true | 210 | return true | ||
| 195 | } | 211 | } | ||
| 196 | 212 | ||||
| @@ -213,28 +229,15 @@ func (m *Manager) downloadCompileAndActivate(ctx context.Context) { | |||||
| 213 | return | 229 | 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 | return | 236 | 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 = filter | 293 | + | 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 version | 302 | 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.Version | 325 | 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.filter | 340 | + | 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.filter | 357 | + | 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 compile | 395 | + | 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 = filter | 411 | + | 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 nil | 413 | 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 != nil | 419 | + | 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 loaded | 528 | // Try to load filters if not already loaded | ||
| 549 | m.filterMu.RLock() | 529 | m.filterMu.RLock() | ||
| 550 | - | hasFilter := m.filter != nil | 530 | + | 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 = nil | 567 | + | 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 ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | + | "strings" | |
| 8 | 9 | "sync" | |
| 9 | 10 | "testing" | |
| 10 | 11 | "time" | |
| @@ -39,14 +40,14 @@ func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) { | |||
| 39 | 40 | mockStore := mocks.NewMockFilterStore(t) | |
| 40 | 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 | - | // 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 | |
| 44 | 45 | mockStore.EXPECT(). | |
| 45 | - | HasCompiledFilter(mock.Anything, filtering.FilterIdentifier). | |
| 46 | - | Return(true) | |
| 46 | + | FetchIdentifiers(mock.Anything). | |
| 47 | + | Return([]string{"ublock-combined-0"}, nil).Once() | |
| 47 | 48 | ||
| 48 | 49 | mockStore.EXPECT(). | |
| 49 | - | Load(mock.Anything, filtering.FilterIdentifier). | |
| 50 | + | Load(mock.Anything, "ublock-combined-0"). | |
| 50 | 51 | Return(nil, nil) // nil filter triggers fallback to download | |
| 51 | 52 | ||
| 52 | 53 | // Fallback: download will be triggered since Load returned nil filter | |
| @@ -55,7 +56,7 @@ func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) { | |||
| 55 | 56 | Return([]string{jsonFile}, nil) | |
| 56 | 57 | ||
| 57 | 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 | 60 | Return(&webkit.UserContentFilter{}, nil) | |
| 60 | 61 | ||
| 61 | 62 | mockDownloader.EXPECT(). | |
| @@ -135,8 +136,8 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) { | |||
| 135 | 136 | ||
| 136 | 137 | // Setup expectations: no filter in store | |
| 137 | 138 | mockStore.EXPECT(). | |
| 138 | - | HasCompiledFilter(mock.Anything, filtering.FilterIdentifier). | |
| 139 | - | Return(false) | |
| 139 | + | FetchIdentifiers(mock.Anything). | |
| 140 | + | Return([]string{}, nil) | |
| 140 | 141 | ||
| 141 | 142 | // Download should be triggered | |
| 142 | 143 | mockDownloader.EXPECT(). | |
| @@ -145,7 +146,7 @@ func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) { | |||
| 145 | 146 | ||
| 146 | 147 | // Compile should be called | |
| 147 | 148 | mockStore.EXPECT(). | |
| 148 | - | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | |
| 149 | + | Compile(mock.Anything, "ublock-combined-0", mock.Anything). | |
| 149 | 150 | Return(&webkit.UserContentFilter{}, nil) | |
| 150 | 151 | ||
| 151 | 152 | // GetCachedManifest for version | |
| @@ -242,7 +243,7 @@ func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T) | |||
| 242 | 243 | ||
| 243 | 244 | // Compile called | |
| 244 | 245 | mockStore.EXPECT(). | |
| 245 | - | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | |
| 246 | + | Compile(mock.Anything, "ublock-combined-0", mock.Anything). | |
| 246 | 247 | Return(&webkit.UserContentFilter{}, nil) | |
| 247 | 248 | ||
| 248 | 249 | // GetCachedManifest for version | |
| @@ -363,9 +364,15 @@ func TestManager_Clear_RemovesFilterAndCache(t *testing.T) { | |||
| 363 | 364 | mockStore := mocks.NewMockFilterStore(t) | |
| 364 | 365 | mockDownloader := mocks.NewMockFilterDownloader(t) | |
| 365 | 366 | ||
| 366 | - | // Remove should be called | |
| 367 | + | // FetchIdentifiers + Remove should be called for each part | |
| 367 | 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 | 376 | Return(nil) | |
| 370 | 377 | ||
| 371 | 378 | // ClearCache should be called | |
| @@ -443,16 +450,16 @@ func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) { | |||
| 443 | 450 | ||
| 444 | 451 | // Setup for a load with fallback to download (since we can't return real filter) | |
| 445 | 452 | mockStore.EXPECT(). | |
| 446 | - | HasCompiledFilter(mock.Anything, filtering.FilterIdentifier). | |
| 447 | - | Return(true) | |
| 453 | + | FetchIdentifiers(mock.Anything). | |
| 454 | + | Return([]string{"ublock-combined-0"}, nil).Once() | |
| 448 | 455 | mockStore.EXPECT(). | |
| 449 | - | Load(mock.Anything, filtering.FilterIdentifier). | |
| 456 | + | Load(mock.Anything, "ublock-combined-0"). | |
| 450 | 457 | Return(nil, nil) // nil triggers download fallback | |
| 451 | 458 | mockDownloader.EXPECT(). | |
| 452 | 459 | DownloadFilters(mock.Anything, mock.Anything). | |
| 453 | 460 | Return([]string{jsonFile}, nil) | |
| 454 | 461 | mockStore.EXPECT(). | |
| 455 | - | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | |
| 462 | + | Compile(mock.Anything, "ublock-combined-0", mock.Anything). | |
| 456 | 463 | Return(&webkit.UserContentFilter{}, nil) | |
| 457 | 464 | mockDownloader.EXPECT(). | |
| 458 | 465 | GetCachedManifest(). | |
| @@ -510,8 +517,8 @@ func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) { | |||
| 510 | 517 | mockDownloader := mocks.NewMockFilterDownloader(t) | |
| 511 | 518 | ||
| 512 | 519 | mockStore.EXPECT(). | |
| 513 | - | HasCompiledFilter(mock.Anything, filtering.FilterIdentifier). | |
| 514 | - | Return(false) | |
| 520 | + | FetchIdentifiers(mock.Anything). | |
| 521 | + | Return([]string{}, nil) | |
| 515 | 522 | ||
| 516 | 523 | mockDownloader.EXPECT(). | |
| 517 | 524 | DownloadFilters(mock.Anything, mock.Anything). | |
| @@ -561,19 +568,21 @@ func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) { | |||
| 561 | 568 | ||
| 562 | 569 | activeFilter := &webkit.UserContentFilter{} | |
| 563 | 570 | ||
| 571 | + | // loadFromCache succeeds — returns activeFilter | |
| 564 | 572 | mockStore.EXPECT(). | |
| 565 | - | HasCompiledFilter(mock.Anything, filtering.FilterIdentifier). | |
| 566 | - | Return(true).Once() | |
| 573 | + | FetchIdentifiers(mock.Anything). | |
| 574 | + | Return([]string{"ublock-combined-0"}, nil).Once() | |
| 567 | 575 | mockStore.EXPECT(). | |
| 568 | - | Load(mock.Anything, filtering.FilterIdentifier). | |
| 576 | + | Load(mock.Anything, "ublock-combined-0"). | |
| 569 | 577 | Return(activeFilter, nil).Once() | |
| 570 | 578 | mockDownloader.EXPECT(). | |
| 571 | 579 | GetCachedManifest(). | |
| 572 | - | Return(&filtering.Manifest{Version: "2025.12.19"}, nil).Once() | |
| 580 | + | Return(&filtering.Manifest{Version: "2025.12.19"}, nil) | |
| 573 | 581 | mockDownloader.EXPECT(). | |
| 574 | 582 | IsCacheStale(filtering.CacheMaxAge). | |
| 575 | 583 | Return(false).Once() | |
| 576 | 584 | ||
| 585 | + | // CheckForUpdates: compile fails, should keep active filter | |
| 577 | 586 | mockDownloader.EXPECT(). | |
| 578 | 587 | NeedsUpdate(mock.Anything). | |
| 579 | 588 | Return(true, nil).Once() | |
| @@ -581,7 +590,7 @@ func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) { | |||
| 581 | 590 | DownloadFilters(mock.Anything, mock.Anything). | |
| 582 | 591 | Return([]string{jsonFile}, nil).Once() | |
| 583 | 592 | mockStore.EXPECT(). | |
| 584 | - | Compile(mock.Anything, filtering.FilterIdentifier, mock.Anything). | |
| 593 | + | Compile(mock.Anything, "ublock-combined-0", mock.Anything). | |
| 585 | 594 | Return(nil, errors.New("compile failed")).Once() | |
| 586 | 595 | ||
| 587 | 596 | mgr, err := filtering.NewManager(filtering.ManagerConfig{ | |
| @@ -599,11 +608,12 @@ func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) { | |||
| 599 | 608 | mgr.LoadAsync(ctx) | |
| 600 | 609 | ||
| 601 | 610 | 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 | |
| 603 | 612 | }, 3*time.Second, 50*time.Millisecond) | |
| 604 | 613 | ||
| 605 | 614 | err = mgr.CheckForUpdates(ctx) | |
| 606 | 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 | 618 | assert.Equal(t, filtering.StateActive, mgr.Status().State) | |
| 609 | 619 | } | |
| @@ -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 taken | 44 | + | // 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 download | 51 | Return(nil, nil) // nil filter triggers fallback to download | ||
| 51 | 52 | ||||
| 52 | // Fallback: download will be triggered since Load returned nil filter | 53 | // 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 store | 137 | // 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 triggered | 142 | // 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 called | 147 | // 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 version | 152 | // GetCachedManifest for version | ||
| @@ -242,7 +243,7 @@ func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T) | |||||
| 242 | 243 | ||||
| 243 | // Compile called | 244 | // 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 version | 249 | // 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 called | 367 | + | // 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 called | 378 | // 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 fallback | 457 | 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.StateActive | 611 | + | 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 { | |||
| 48 | 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 | 52 | var FilterFiles = struct { | |
| 53 | 53 | Manifest string | |
| 54 | - | Combined []string | |
| 55 | 54 | }{ | |
| 56 | 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. | |
| 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" | |
| 66 | 61 | ||
| 67 | 62 | // GitHubReleaseURL is the base URL for downloading filter files. | |
| 68 | 63 | 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 string | 53 | 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() { | |||
| 418 | 418 | wv.connectURISignal() | |
| 419 | 419 | wv.connectFaviconSignal() | |
| 420 | 420 | wv.connectProgressSignal() | |
| 421 | + | wv.connectLoadFailedSignal() | |
| 421 | 422 | wv.connectDecidePolicySignal() | |
| 422 | 423 | wv.connectEnterFullscreenSignal() | |
| 423 | 424 | wv.connectLeaveFullscreenSignal() | |
| @@ -464,6 +465,21 @@ func (wv *WebView) connectLoadChangedSignal() { | |||
| 464 | 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 | 483 | func (wv *WebView) connectCloseSignal() { | |
| 468 | 484 | closeCb := func(_ webkit.WebView) { | |
| 469 | 485 | 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 { | ||