v0.29.0 21b3150

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

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