manager.go RawHistoryFolder

1package filtering
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"os"
9	"path/filepath"
10	"strings"
11	"sync"
12	"sync/atomic"
13	"time"
14
15	"github.com/bnema/dumber/internal/application/port"
16	"github.com/bnema/dumber/internal/logging"
17	"github.com/bnema/puregotk/v4/webkit"
18)
19
20// Compile-time check that Manager satisfies port.FilterManager.
21var _ port.FilterManager = (*Manager)(nil)
22
23const (
24	storeDirPerm   = 0o755
25	jsonDirPerm    = 0o755
26	quarantinePerm = 0o755
27	mergedFilePerm = 0o644
28
29	// CacheMaxAge is the maximum age of the filter cache before it's considered stale.
30	CacheMaxAge = 24 * time.Hour
31)
32
33// ErrUpdateSkipped indicates a filter update was intentionally skipped after a recoverable
34// update-path failure (e.g. invalid payload, merge failure, compile failure).
35var ErrUpdateSkipped = errors.New("filter update skipped")
36
37// Manager orchestrates the content filter lifecycle.
38// It handles downloading, compiling, loading, and applying filters to WebViews.
39type Manager struct {
40	store      FilterStore
41	downloader FilterDownloader
42
43	storeDir string
44	jsonDir  string
45
46	filters    []*webkit.UserContentFilter
47	filterMu   sync.RWMutex
48	status     atomic.Value // FilterStatus
49	enabled    bool
50	autoUpdate bool
51
52	// Callbacks for status updates (e.g., toast notifications)
53	onStatusChange func(FilterStatus)
54}
55
56// ManagerConfig holds configuration for the filter manager.
57type ManagerConfig struct {
58	StoreDir   string // Where to store compiled filter bytecode
59	JSONDir    string // Where to cache downloaded JSON files
60	Enabled    bool   // Whether filtering is enabled
61	AutoUpdate bool   // Whether to auto-update filters
62
63	// Optional: custom implementations for testing
64	Store      FilterStore      // If nil, creates default Store
65	Downloader FilterDownloader // If nil, creates default Downloader
66}
67
68// NewManager creates a new filter Manager.
69func NewManager(cfg ManagerConfig) (*Manager, error) {
70	// Ensure directories exist
71	if err := os.MkdirAll(cfg.StoreDir, storeDirPerm); err != nil {
72		return nil, fmt.Errorf("failed to create store dir: %w", err)
73	}
74	if err := os.MkdirAll(cfg.JSONDir, jsonDirPerm); err != nil {
75		return nil, fmt.Errorf("failed to create json dir: %w", err)
76	}
77
78	// Use provided implementations or create defaults
79	store := cfg.Store
80	if store == nil {
81		newStore := NewStore(cfg.StoreDir)
82		if newStore == nil {
83			return nil, fmt.Errorf("failed to create filter store at %s", cfg.StoreDir)
84		}
85		store = newStore
86	}
87
88	downloader := cfg.Downloader
89	if downloader == nil {
90		downloader = NewDownloader(cfg.JSONDir)
91	}
92
93	m := &Manager{
94		store:      store,
95		downloader: downloader,
96		storeDir:   cfg.StoreDir,
97		jsonDir:    cfg.JSONDir,
98		enabled:    cfg.Enabled,
99		autoUpdate: cfg.AutoUpdate,
100	}
101
102	m.setStatus(FilterStatus{State: StateUninitialized})
103	return m, nil
104}
105
106// SetStatusCallback sets a callback for status changes (e.g., for toast notifications).
107func (m *Manager) SetStatusCallback(cb func(FilterStatus)) {
108	m.onStatusChange = cb
109}
110
111// setStatus updates the current status and notifies callback.
112func (m *Manager) setStatus(status FilterStatus) {
113	m.status.Store(status)
114	if m.onStatusChange != nil {
115		m.onStatusChange(status)
116	}
117}
118
119// Status returns the current filter status.
120func (m *Manager) Status() FilterStatus {
121	if s, ok := m.status.Load().(FilterStatus); ok {
122		return s
123	}
124	return FilterStatus{State: StateUninitialized}
125}
126
127// Initialize performs fast initialization.
128// This is called BEFORE GTK starts, so we cannot use GIO async operations.
129// Just sets up state - actual filter loading happens in LoadAsync after GTK starts.
130func (m *Manager) Initialize(ctx context.Context) error {
131	log := logging.FromContext(ctx).With().
132		Str("component", "filter-manager").
133		Logger()
134
135	if !m.enabled {
136		log.Info().Msg("content filtering disabled")
137		m.setStatus(FilterStatus{State: StateDisabled})
138		return nil
139	}
140
141	log.Debug().Msg("filter manager initialized (filters will load after GTK starts)")
142	m.setStatus(FilterStatus{State: StateUninitialized, Message: "Filters pending"})
143	return nil
144}
145
146// LoadAsync loads or downloads filters in the background.
147// Call this after the browser window is shown (GTK main loop running).
148// First tries to load from cache, then downloads if needed.
149func (m *Manager) LoadAsync(ctx context.Context) {
150	if !m.enabled {
151		return
152	}
153
154	go m.loadAsyncWorker(ctx)
155}
156
157func (m *Manager) loadAsyncWorker(ctx context.Context) {
158	log := logging.FromContext(ctx).With().
159		Str("component", "filter-manager").
160		Logger()
161
162	if m.hasActiveFilter() {
163		log.Debug().Msg("filters already loaded, skipping async load")
164		return
165	}
166
167	if m.loadFromCache(ctx) {
168		m.checkStaleCacheAndUpdate(ctx)
169		return
170	}
171
172	// If local JSON files exist on disk, compile from them directly instead
173	// of downloading. This respects auto_update=false on cold start with an
174	// empty compiled store and allows testing with patched local JSON.
175	if paths := m.downloader.GetCachedFilterPaths(); len(paths) > 0 {
176		log.Info().Int("files", len(paths)).Msg("compiling filters from local JSON (skipping download)")
177		filters, err := m.compileFilterParts(ctx, paths)
178		if err == nil {
179			version := m.setActiveFilters(filters, "Filters active")
180			log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters compiled from local JSON")
181			return
182		}
183		log.Warn().Err(err).Msg("failed to compile local JSON, falling back to download")
184	}
185
186	m.downloadCompileAndActivate(ctx)
187}
188
189func (m *Manager) loadFromCache(ctx context.Context) bool {
190	log := logging.FromContext(ctx).With().
191		Str("component", "filter-manager").
192		Logger()
193
194	m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
195
196	// Discover how many compiled parts exist via stored identifiers
197	identifiers, err := m.store.FetchIdentifiers(ctx)
198	if err != nil {
199		return false
200	}
201	var partIDs []string
202	for _, id := range identifiers {
203		if strings.HasPrefix(id, FilterIdentifierPrefix) {
204			partIDs = append(partIDs, id)
205		}
206	}
207	if len(partIDs) == 0 {
208		return false
209	}
210
211	partIDs = m.compiledIDsForCachedManifest(partIDs)
212	if len(partIDs) == 0 {
213		return false
214	}
215
216	log.Debug().Int("parts", len(partIDs)).Msg("found compiled filters, loading from cache")
217	var filters []*webkit.UserContentFilter
218	for _, id := range partIDs {
219		filter, loadErr := m.store.Load(ctx, id)
220		if loadErr != nil || filter == nil {
221			log.Warn().Err(loadErr).Str("id", id).Msg("failed to load cached filter part, will download")
222			return false
223		}
224		filters = append(filters, filter)
225	}
226
227	version := m.setActiveFilters(filters, "Filters active")
228	log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters loaded from cache")
229	return true
230}
231
232func (m *Manager) downloadCompileAndActivate(ctx context.Context) {
233	log := logging.FromContext(ctx).With().
234		Str("component", "filter-manager").
235		Logger()
236
237	paths, err := m.downloadFiltersWithProgress(ctx)
238	if err != nil {
239		log.Error().Err(err).Msg("failed to download filters")
240		m.setStatus(FilterStatus{State: StateError, Message: "Download failed"})
241		return
242	}
243
244	validateErr := m.validateDownloadedFiles(ctx, paths)
245	if validateErr != nil {
246		log.Error().Err(validateErr).Msg("downloaded filter validation failed")
247		m.setStatus(FilterStatus{State: StateError, Message: "Invalid downloaded filters"})
248		return
249	}
250
251	filters, err := m.compileFilterParts(ctx, paths)
252	if err != nil {
253		log.Error().Err(err).Msg("failed to compile filters")
254		m.handleCompilationFailure()
255		return
256	}
257
258	version := m.setActiveFilters(filters, "Filters active")
259	log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters compiled and active")
260}
261
262func (m *Manager) downloadFiltersWithProgress(ctx context.Context) ([]string, error) {
263	m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."})
264	return m.downloader.DownloadFilters(ctx, func(p DownloadProgress) {
265		msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total)
266		m.setStatus(FilterStatus{State: StateLoading, Message: msg})
267	})
268}
269
270// compileFilterParts compiles each downloaded part file as a separate WebKit filter.
271// Each part gets a unique identifier (e.g., "ublock-combined-0", "ublock-combined-1").
272func (m *Manager) compileFilterParts(ctx context.Context, paths []string) ([]*webkit.UserContentFilter, error) {
273	log := logging.FromContext(ctx).With().
274		Str("component", "filter-manager").
275		Logger()
276
277	m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})
278
279	filters := make([]*webkit.UserContentFilter, 0, len(paths))
280	for i, path := range paths {
281		identifier := fmt.Sprintf("%s-%d", FilterIdentifierPrefix, i)
282		log.Debug().Str("id", identifier).Str("path", path).Msg("compiling filter part")
283
284		filter, err := m.store.Compile(ctx, identifier, path)
285		if err != nil {
286			return nil, fmt.Errorf("compile %s: %w", identifier, err)
287		}
288		if filter == nil {
289			return nil, fmt.Errorf("compile %s returned nil filter", identifier)
290		}
291		filters = append(filters, filter)
292	}
293
294	return filters, nil
295}
296
297func (m *Manager) handleCompilationFailure() {
298	if m.hasActiveFilter() {
299		version := m.getCachedVersion()
300		m.setStatus(FilterStatus{
301			State:   StateActive,
302			Message: "Filters active (update skipped)",
303			Version: version,
304		})
305		return
306	}
307	m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
308}
309
310func (m *Manager) setActiveFilters(filters []*webkit.UserContentFilter, message string) string {
311	m.filterMu.Lock()
312	m.filters = filters
313	m.filterMu.Unlock()
314
315	version := m.getCachedVersion()
316	m.setStatus(FilterStatus{
317		State:   StateActive,
318		Message: message,
319		Version: version,
320	})
321	return version
322}
323
324func (m *Manager) compiledIDsForCachedManifest(storedIDs []string) []string {
325	manifest, err := m.downloader.GetCachedManifest()
326	if err != nil || manifest == nil || len(manifest.Combined.Files) == 0 {
327		return storedIDs
328	}
329
330	available := make(map[string]struct{}, len(storedIDs))
331	for _, id := range storedIDs {
332		available[id] = struct{}{}
333	}
334
335	expectedIDs := expectedCompiledFilterIDs(len(manifest.Combined.Files))
336	ids := make([]string, 0, len(expectedIDs))
337	for _, id := range expectedIDs {
338		if _, ok := available[id]; !ok {
339			return nil
340		}
341		ids = append(ids, id)
342	}
343	return ids
344}
345
346// checkStaleCacheAndUpdate checks for updates after loading cached filters.
347func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
348	log := logging.FromContext(ctx)
349	log.Info().Msg("checking for filter updates in background")
350	go func() {
351		if err := m.CheckForUpdates(ctx); err != nil {
352			log.Warn().Err(err).Msg("background filter update check failed")
353		}
354	}()
355}
356
357func expectedCompiledFilterIDs(count int) []string {
358	ids := make([]string, 0, count)
359	for i := range count {
360		ids = append(ids, fmt.Sprintf("%s-%d", FilterIdentifierPrefix, i))
361	}
362	return ids
363}
364
365func (m *Manager) pruneObsoleteCompiledFilters(ctx context.Context, expectedCount int) {
366	log := logging.FromContext(ctx).With().
367		Str("component", "filter-manager").
368		Logger()
369
370	identifiers, err := m.store.FetchIdentifiers(ctx)
371	if err != nil {
372		log.Warn().Err(err).Msg("failed to fetch compiled filter identifiers for pruning")
373		return
374	}
375
376	expected := make(map[string]struct{}, expectedCount)
377	for _, id := range expectedCompiledFilterIDs(expectedCount) {
378		expected[id] = struct{}{}
379	}
380
381	for _, id := range identifiers {
382		if !strings.HasPrefix(id, FilterIdentifierPrefix) {
383			continue
384		}
385		if _, ok := expected[id]; ok {
386			continue
387		}
388		if err := m.store.Remove(ctx, id); err != nil {
389			log.Warn().Err(err).Str("id", id).Msg("failed to remove obsolete compiled filter")
390		}
391	}
392}
393
394// getCachedVersion returns the version from the cached manifest.
395func (m *Manager) getCachedVersion() string {
396	manifest, err := m.downloader.GetCachedManifest()
397	if err != nil || manifest == nil {
398		return "unknown"
399	}
400	return manifest.Version
401}
402
403// ApplyTo adds the active filters to a WebView's UserContentManager.
404// Safe to call even if filters are not yet loaded (no-op in that case).
405func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) {
406	log := logging.FromContext(ctx).With().
407		Str("component", "filter-manager").
408		Logger()
409
410	if ucm == nil {
411		return
412	}
413
414	m.filterMu.RLock()
415	filters := m.filters
416	m.filterMu.RUnlock()
417
418	if len(filters) > 0 {
419		for _, filter := range filters {
420			ucm.AddFilter(filter)
421		}
422		log.Debug().Int("parts", len(filters)).Msg("content filters applied to webview")
423	} else {
424		log.Debug().Msg("no filter available to apply (filters still loading?)")
425	}
426}
427
428// GetFilters returns the currently loaded filters, or nil if none.
429func (m *Manager) GetFilters() []*webkit.UserContentFilter {
430	m.filterMu.RLock()
431	defer m.filterMu.RUnlock()
432	return m.filters
433}
434
435// CheckForUpdates checks if newer filters are available and downloads them.
436// This should be called periodically in the background.
437func (m *Manager) CheckForUpdates(ctx context.Context) error {
438	if !m.enabled || !m.autoUpdate {
439		return nil
440	}
441
442	log := logging.FromContext(ctx).With().
443		Str("component", "filter-manager").
444		Logger()
445
446	needsUpdate, err := m.downloader.NeedsUpdate(ctx)
447	if err != nil {
448		log.Warn().Err(err).Msg("failed to check for filter updates")
449		return err
450	}
451
452	if !needsUpdate {
453		log.Debug().Msg("filters are up to date")
454		return nil
455	}
456
457	log.Info().Msg("filter update available, downloading")
458
459	// Download new filters
460	paths, err := m.downloader.DownloadFilters(ctx, nil)
461	if err != nil {
462		return fmt.Errorf("failed to download filter update: %w", err)
463	}
464	validateErr := m.validateDownloadedFiles(ctx, paths)
465	if validateErr != nil {
466		log.Warn().Err(validateErr).Msg("invalid downloaded filters, keeping existing active filter")
467		return fmt.Errorf("%w: validation failed: %w", ErrUpdateSkipped, validateErr)
468	}
469
470	filters, err := m.compileFilterParts(ctx, paths)
471	if err != nil {
472		log.Warn().Err(err).Msg("failed to compile updated filters, keeping existing active filter")
473		// Restore status if we still have an active filter
474		if m.hasActiveFilter() {
475			version := m.getCachedVersion()
476			m.setStatus(FilterStatus{
477				State:   StateActive,
478				Message: "Filters active (update skipped)",
479				Version: version,
480			})
481		}
482		return fmt.Errorf("%w: compile failed: %w", ErrUpdateSkipped, err)
483	}
484
485	m.pruneObsoleteCompiledFilters(ctx, len(paths))
486
487	version := m.setActiveFilters(filters, "Filters updated")
488	log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters updated successfully")
489
490	return nil
491}
492
493func (m *Manager) hasActiveFilter() bool {
494	m.filterMu.RLock()
495	defer m.filterMu.RUnlock()
496	return len(m.filters) > 0
497}
498
499func (m *Manager) validateDownloadedFiles(ctx context.Context, paths []string) error {
500	var validationErrs []error
501	var quarantineErrs []error
502
503	for _, path := range paths {
504		if err := validateRuleFile(path); err != nil {
505			if quarantineErr := m.quarantineInvalidFile(ctx, path, "rule_validation_failed"); quarantineErr != nil {
506				quarantineErrs = append(quarantineErrs, fmt.Errorf("file=%s: %w", path, quarantineErr))
507			}
508			validationErrs = append(validationErrs, fmt.Errorf("file=%s: %w", path, err))
509		}
510	}
511
512	if len(validationErrs) == 0 && len(quarantineErrs) == 0 {
513		return nil
514	}
515
516	var parts []string
517	if len(validationErrs) > 0 {
518		parts = append(parts, fmt.Sprintf("%d invalid file(s)", len(validationErrs)))
519	}
520	if len(quarantineErrs) > 0 {
521		parts = append(parts, fmt.Sprintf("%d quarantine failure(s)", len(quarantineErrs)))
522	}
523
524	return fmt.Errorf(
525		"downloaded filter validation failed (%s): %w",
526		strings.Join(parts, ", "),
527		errors.Join(append(validationErrs, quarantineErrs...)...),
528	)
529}
530
531func validateRuleFile(path string) error {
532	data, err := os.ReadFile(path)
533	if err != nil {
534		return fmt.Errorf("read failed: %w", err)
535	}
536
537	var rules []map[string]json.RawMessage
538	if err := json.Unmarshal(data, &rules); err != nil {
539		return fmt.Errorf("json parse failed: %w", err)
540	}
541	if len(rules) == 0 {
542		return fmt.Errorf("empty rule set")
543	}
544
545	for i, rule := range rules {
546		trigger, ok := rule["trigger"]
547		if !ok || len(trigger) == 0 || string(trigger) == "null" {
548			return fmt.Errorf("rule[%d] missing trigger", i)
549		}
550		action, ok := rule["action"]
551		if !ok || len(action) == 0 || string(action) == "null" {
552			return fmt.Errorf("rule[%d] missing action", i)
553		}
554		var actionObj struct {
555			Type string `json:"type"`
556		}
557		if err := json.Unmarshal(action, &actionObj); err != nil {
558			return fmt.Errorf("rule[%d] action parse failed: %w", i, err)
559		}
560		if actionObj.Type == "" {
561			return fmt.Errorf("rule[%d] action.type missing", i)
562		}
563	}
564
565	return nil
566}
567
568func (m *Manager) quarantineInvalidFile(ctx context.Context, srcPath, cause string) error {
569	log := logging.FromContext(ctx).With().
570		Str("component", "filter-manager").
571		Logger()
572
573	quarantineDir := filepath.Join(m.jsonDir, "quarantine")
574	if err := os.MkdirAll(quarantineDir, quarantinePerm); err != nil {
575		return fmt.Errorf("create quarantine dir failed: %w", err)
576	}
577
578	baseName := filepath.Base(srcPath)
579	quarantinePath := filepath.Join(
580		quarantineDir,
581		fmt.Sprintf("%d-%s", time.Now().UnixNano(), baseName),
582	)
583	if err := os.Rename(srcPath, quarantinePath); err != nil {
584		return fmt.Errorf("move to quarantine failed: %w", err)
585	}
586
587	log.Error().
588		Str("path", srcPath).
589		Str("quarantine_path", quarantinePath).
590		Str("cause", cause).
591		Msg("quarantined invalid filter file")
592
593	return nil
594}
595
596// Enable enables content filtering.
597func (m *Manager) Enable(ctx context.Context) {
598	log := logging.FromContext(ctx).With().
599		Str("component", "filter-manager").
600		Logger()
601
602	m.enabled = true
603	log.Info().Msg("content filtering enabled")
604
605	// Try to load filters if not already loaded
606	m.filterMu.RLock()
607	hasFilters := len(m.filters) > 0
608	m.filterMu.RUnlock()
609
610	if !hasFilters {
611		m.LoadAsync(ctx)
612	} else {
613		m.setStatus(FilterStatus{
614			State:   StateActive,
615			Message: "Filters active",
616			Version: m.getCachedVersion(),
617		})
618	}
619}
620
621// Disable disables content filtering.
622func (m *Manager) Disable(ctx context.Context) {
623	log := logging.FromContext(ctx).With().
624		Str("component", "filter-manager").
625		Logger()
626
627	m.enabled = false
628	m.setStatus(FilterStatus{State: StateDisabled})
629	log.Info().Msg("content filtering disabled")
630}
631
632// IsEnabled returns whether content filtering is enabled.
633func (m *Manager) IsEnabled() bool {
634	return m.enabled
635}
636
637// Clear removes all compiled and cached filters.
638func (m *Manager) Clear(ctx context.Context) error {
639	log := logging.FromContext(ctx).With().
640		Str("component", "filter-manager").
641		Logger()
642
643	m.filterMu.Lock()
644	m.filters = nil
645	m.filterMu.Unlock()
646
647	// Remove all compiled filter parts
648	identifiers, err := m.store.FetchIdentifiers(ctx)
649	if err != nil {
650		log.Warn().Err(err).Msg("failed to fetch identifiers for cleanup")
651	}
652	for _, id := range identifiers {
653		if strings.HasPrefix(id, FilterIdentifierPrefix) {
654			if err := m.store.Remove(ctx, id); err != nil {
655				log.Warn().Err(err).Str("id", id).Msg("failed to remove compiled filter")
656			}
657		}
658	}
659
660	if err := m.downloader.ClearCache(); err != nil {
661		log.Warn().Err(err).Msg("failed to clear download cache")
662	}
663
664	m.setStatus(FilterStatus{State: StateUninitialized})
665	log.Info().Msg("filters cleared")
666	return nil
667}

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