v0.28.0 3cb3770

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-webkit/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	m.downloadCompileAndActivate(ctx)
173}
174
175func (m *Manager) loadFromCache(ctx context.Context) bool {
176	log := logging.FromContext(ctx).With().
177		Str("component", "filter-manager").
178		Logger()
179
180	m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
181
182	// Discover how many compiled parts exist via stored identifiers
183	identifiers, err := m.store.FetchIdentifiers(ctx)
184	if err != nil {
185		return false
186	}
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 {
194		return false
195	}
196
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")
210	return true
211}
212
213func (m *Manager) downloadCompileAndActivate(ctx context.Context) {
214	log := logging.FromContext(ctx).With().
215		Str("component", "filter-manager").
216		Logger()
217
218	paths, err := m.downloadFiltersWithProgress(ctx)
219	if err != nil {
220		log.Error().Err(err).Msg("failed to download filters")
221		m.setStatus(FilterStatus{State: StateError, Message: "Download failed"})
222		return
223	}
224
225	validateErr := m.validateDownloadedFiles(ctx, paths)
226	if validateErr != nil {
227		log.Error().Err(validateErr).Msg("downloaded filter validation failed")
228		m.setStatus(FilterStatus{State: StateError, Message: "Invalid downloaded filters"})
229		return
230	}
231
232	filters, err := m.compileFilterParts(ctx, paths)
233	if err != nil {
234		log.Error().Err(err).Msg("failed to compile filters")
235		m.handleCompilationFailure()
236		return
237	}
238
239	version := m.setActiveFilters(filters, "Filters active")
240	log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters compiled and active")
241}
242
243func (m *Manager) downloadFiltersWithProgress(ctx context.Context) ([]string, error) {
244	m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."})
245	return m.downloader.DownloadFilters(ctx, func(p DownloadProgress) {
246		msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total)
247		m.setStatus(FilterStatus{State: StateLoading, Message: msg})
248	})
249}
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").
253func (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
278func (m *Manager) handleCompilationFailure() {
279	if m.hasActiveFilter() {
280		version := m.getCachedVersion()
281		m.setStatus(FilterStatus{
282			State:   StateActive,
283			Message: "Filters active (update skipped)",
284			Version: version,
285		})
286		return
287	}
288	m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
289}
290
291func (m *Manager) setActiveFilters(filters []*webkit.UserContentFilter, message string) string {
292	m.filterMu.Lock()
293	m.filters = filters
294	m.filterMu.Unlock()
295
296	version := m.getCachedVersion()
297	m.setStatus(FilterStatus{
298		State:   StateActive,
299		Message: message,
300		Version: version,
301	})
302	return version
303}
304
305// checkStaleCacheAndUpdate checks if the cache is stale and triggers a background update.
306func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
307	if !m.downloader.IsCacheStale(CacheMaxAge) {
308		return
309	}
310	log := logging.FromContext(ctx)
311	log.Info().Msg("filter cache is stale, checking for updates in background")
312	go func() {
313		if err := m.CheckForUpdates(ctx); err != nil {
314			log.Warn().Err(err).Msg("background filter update check failed")
315		}
316	}()
317}
318
319// getCachedVersion returns the version from the cached manifest.
320func (m *Manager) getCachedVersion() string {
321	manifest, err := m.downloader.GetCachedManifest()
322	if err != nil || manifest == nil {
323		return "unknown"
324	}
325	return manifest.Version
326}
327
328// ApplyTo adds the active filters to a WebView's UserContentManager.
329// Safe to call even if filters are not yet loaded (no-op in that case).
330func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) {
331	log := logging.FromContext(ctx).With().
332		Str("component", "filter-manager").
333		Logger()
334
335	if ucm == nil {
336		return
337	}
338
339	m.filterMu.RLock()
340	filters := m.filters
341	m.filterMu.RUnlock()
342
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")
348	} else {
349		log.Debug().Msg("no filter available to apply (filters still loading?)")
350	}
351}
352
353// GetFilters returns the currently loaded filters, or nil if none.
354func (m *Manager) GetFilters() []*webkit.UserContentFilter {
355	m.filterMu.RLock()
356	defer m.filterMu.RUnlock()
357	return m.filters
358}
359
360// CheckForUpdates checks if newer filters are available and downloads them.
361// This should be called periodically in the background.
362func (m *Manager) CheckForUpdates(ctx context.Context) error {
363	if !m.enabled || !m.autoUpdate {
364		return nil
365	}
366
367	log := logging.FromContext(ctx).With().
368		Str("component", "filter-manager").
369		Logger()
370
371	needsUpdate, err := m.downloader.NeedsUpdate(ctx)
372	if err != nil {
373		log.Warn().Err(err).Msg("failed to check for filter updates")
374		return err
375	}
376
377	if !needsUpdate {
378		log.Debug().Msg("filters are up to date")
379		return nil
380	}
381
382	log.Info().Msg("filter update available, downloading")
383
384	// Download new filters
385	paths, err := m.downloader.DownloadFilters(ctx, nil)
386	if err != nil {
387		return fmt.Errorf("failed to download filter update: %w", err)
388	}
389	validateErr := m.validateDownloadedFiles(ctx, paths)
390	if validateErr != nil {
391		log.Warn().Err(validateErr).Msg("invalid downloaded filters, keeping existing active filter")
392		return fmt.Errorf("%w: validation failed: %w", ErrUpdateSkipped, validateErr)
393	}
394
395	filters, err := m.compileFilterParts(ctx, paths)
396	if err != nil {
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		}
407		return fmt.Errorf("%w: compile failed: %w", ErrUpdateSkipped, err)
408	}
409
410	version := m.setActiveFilters(filters, "Filters updated")
411	log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters updated successfully")
412
413	return nil
414}
415
416func (m *Manager) hasActiveFilter() bool {
417	m.filterMu.RLock()
418	defer m.filterMu.RUnlock()
419	return len(m.filters) > 0
420}
421
422func (m *Manager) validateDownloadedFiles(ctx context.Context, paths []string) error {
423	var validationErrs []error
424	var quarantineErrs []error
425
426	for _, path := range paths {
427		if err := validateRuleFile(path); err != nil {
428			if quarantineErr := m.quarantineInvalidFile(ctx, path, "rule_validation_failed"); quarantineErr != nil {
429				quarantineErrs = append(quarantineErrs, fmt.Errorf("file=%s: %w", path, quarantineErr))
430			}
431			validationErrs = append(validationErrs, fmt.Errorf("file=%s: %w", path, err))
432		}
433	}
434
435	if len(validationErrs) == 0 && len(quarantineErrs) == 0 {
436		return nil
437	}
438
439	var parts []string
440	if len(validationErrs) > 0 {
441		parts = append(parts, fmt.Sprintf("%d invalid file(s)", len(validationErrs)))
442	}
443	if len(quarantineErrs) > 0 {
444		parts = append(parts, fmt.Sprintf("%d quarantine failure(s)", len(quarantineErrs)))
445	}
446
447	return fmt.Errorf(
448		"downloaded filter validation failed (%s): %w",
449		strings.Join(parts, ", "),
450		errors.Join(append(validationErrs, quarantineErrs...)...),
451	)
452}
453
454func validateRuleFile(path string) error {
455	data, err := os.ReadFile(path)
456	if err != nil {
457		return fmt.Errorf("read failed: %w", err)
458	}
459
460	var rules []map[string]json.RawMessage
461	if err := json.Unmarshal(data, &rules); err != nil {
462		return fmt.Errorf("json parse failed: %w", err)
463	}
464	if len(rules) == 0 {
465		return fmt.Errorf("empty rule set")
466	}
467
468	for i, rule := range rules {
469		trigger, ok := rule["trigger"]
470		if !ok || len(trigger) == 0 || string(trigger) == "null" {
471			return fmt.Errorf("rule[%d] missing trigger", i)
472		}
473		action, ok := rule["action"]
474		if !ok || len(action) == 0 || string(action) == "null" {
475			return fmt.Errorf("rule[%d] missing action", i)
476		}
477		var actionObj struct {
478			Type string `json:"type"`
479		}
480		if err := json.Unmarshal(action, &actionObj); err != nil {
481			return fmt.Errorf("rule[%d] action parse failed: %w", i, err)
482		}
483		if actionObj.Type == "" {
484			return fmt.Errorf("rule[%d] action.type missing", i)
485		}
486	}
487
488	return nil
489}
490
491func (m *Manager) quarantineInvalidFile(ctx context.Context, srcPath, cause string) error {
492	log := logging.FromContext(ctx).With().
493		Str("component", "filter-manager").
494		Logger()
495
496	quarantineDir := filepath.Join(m.jsonDir, "quarantine")
497	if err := os.MkdirAll(quarantineDir, quarantinePerm); err != nil {
498		return fmt.Errorf("create quarantine dir failed: %w", err)
499	}
500
501	baseName := filepath.Base(srcPath)
502	quarantinePath := filepath.Join(
503		quarantineDir,
504		fmt.Sprintf("%d-%s", time.Now().UnixNano(), baseName),
505	)
506	if err := os.Rename(srcPath, quarantinePath); err != nil {
507		return fmt.Errorf("move to quarantine failed: %w", err)
508	}
509
510	log.Error().
511		Str("path", srcPath).
512		Str("quarantine_path", quarantinePath).
513		Str("cause", cause).
514		Msg("quarantined invalid filter file")
515
516	return nil
517}
518
519// Enable enables content filtering.
520func (m *Manager) Enable(ctx context.Context) {
521	log := logging.FromContext(ctx).With().
522		Str("component", "filter-manager").
523		Logger()
524
525	m.enabled = true
526	log.Info().Msg("content filtering enabled")
527
528	// Try to load filters if not already loaded
529	m.filterMu.RLock()
530	hasFilters := len(m.filters) > 0
531	m.filterMu.RUnlock()
532
533	if !hasFilters {
534		m.LoadAsync(ctx)
535	} else {
536		m.setStatus(FilterStatus{
537			State:   StateActive,
538			Message: "Filters active",
539			Version: m.getCachedVersion(),
540		})
541	}
542}
543
544// Disable disables content filtering.
545func (m *Manager) Disable(ctx context.Context) {
546	log := logging.FromContext(ctx).With().
547		Str("component", "filter-manager").
548		Logger()
549
550	m.enabled = false
551	m.setStatus(FilterStatus{State: StateDisabled})
552	log.Info().Msg("content filtering disabled")
553}
554
555// IsEnabled returns whether content filtering is enabled.
556func (m *Manager) IsEnabled() bool {
557	return m.enabled
558}
559
560// Clear removes all compiled and cached filters.
561func (m *Manager) Clear(ctx context.Context) error {
562	log := logging.FromContext(ctx).With().
563		Str("component", "filter-manager").
564		Logger()
565
566	m.filterMu.Lock()
567	m.filters = nil
568	m.filterMu.Unlock()
569
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		}
581	}
582
583	if err := m.downloader.ClearCache(); err != nil {
584		log.Warn().Err(err).Msg("failed to clear download cache")
585	}
586
587	m.setStatus(FilterStatus{State: StateUninitialized})
588	log.Info().Msg("filters cleared")
589	return nil
590}

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