v0.23.2 bfd5eb4

manager.go RawHistoryFolder

1package filtering
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"os"
8	"path/filepath"
9	"sync"
10	"sync/atomic"
11	"time"
12
13	"github.com/bnema/dumber/internal/logging"
14	"github.com/bnema/puregotk-webkit/webkit"
15)
16
17const (
18	storeDirPerm   = 0o755
19	jsonDirPerm    = 0o755
20	mergedFilePerm = 0o644
21
22	// CacheMaxAge is the maximum age of the filter cache before it's considered stale.
23	CacheMaxAge = 24 * time.Hour
24)
25
26// Manager orchestrates the content filter lifecycle.
27// It handles downloading, compiling, loading, and applying filters to WebViews.
28type Manager struct {
29	store      FilterStore
30	downloader FilterDownloader
31
32	storeDir string
33	jsonDir  string
34
35	filter     *webkit.UserContentFilter
36	filterMu   sync.RWMutex
37	status     atomic.Value // FilterStatus
38	enabled    bool
39	autoUpdate bool
40
41	// Callbacks for status updates (e.g., toast notifications)
42	onStatusChange func(FilterStatus)
43}
44
45// ManagerConfig holds configuration for the filter manager.
46type ManagerConfig struct {
47	StoreDir   string // Where to store compiled filter bytecode
48	JSONDir    string // Where to cache downloaded JSON files
49	Enabled    bool   // Whether filtering is enabled
50	AutoUpdate bool   // Whether to auto-update filters
51
52	// Optional: custom implementations for testing
53	Store      FilterStore      // If nil, creates default Store
54	Downloader FilterDownloader // If nil, creates default Downloader
55}
56
57// NewManager creates a new filter Manager.
58func NewManager(cfg ManagerConfig) (*Manager, error) {
59	// Ensure directories exist
60	if err := os.MkdirAll(cfg.StoreDir, storeDirPerm); err != nil {
61		return nil, fmt.Errorf("failed to create store dir: %w", err)
62	}
63	if err := os.MkdirAll(cfg.JSONDir, jsonDirPerm); err != nil {
64		return nil, fmt.Errorf("failed to create json dir: %w", err)
65	}
66
67	// Use provided implementations or create defaults
68	store := cfg.Store
69	if store == nil {
70		newStore := NewStore(cfg.StoreDir)
71		if newStore == nil {
72			return nil, fmt.Errorf("failed to create filter store at %s", cfg.StoreDir)
73		}
74		store = newStore
75	}
76
77	downloader := cfg.Downloader
78	if downloader == nil {
79		downloader = NewDownloader(cfg.JSONDir)
80	}
81
82	m := &Manager{
83		store:      store,
84		downloader: downloader,
85		storeDir:   cfg.StoreDir,
86		jsonDir:    cfg.JSONDir,
87		enabled:    cfg.Enabled,
88		autoUpdate: cfg.AutoUpdate,
89	}
90
91	m.setStatus(FilterStatus{State: StateUninitialized})
92	return m, nil
93}
94
95// SetStatusCallback sets a callback for status changes (e.g., for toast notifications).
96func (m *Manager) SetStatusCallback(cb func(FilterStatus)) {
97	m.onStatusChange = cb
98}
99
100// setStatus updates the current status and notifies callback.
101func (m *Manager) setStatus(status FilterStatus) {
102	m.status.Store(status)
103	if m.onStatusChange != nil {
104		m.onStatusChange(status)
105	}
106}
107
108// Status returns the current filter status.
109func (m *Manager) Status() FilterStatus {
110	if s, ok := m.status.Load().(FilterStatus); ok {
111		return s
112	}
113	return FilterStatus{State: StateUninitialized}
114}
115
116// Initialize performs fast initialization.
117// This is called BEFORE GTK starts, so we cannot use GIO async operations.
118// Just sets up state - actual filter loading happens in LoadAsync after GTK starts.
119func (m *Manager) Initialize(ctx context.Context) error {
120	log := logging.FromContext(ctx).With().
121		Str("component", "filter-manager").
122		Logger()
123
124	if !m.enabled {
125		log.Info().Msg("content filtering disabled")
126		m.setStatus(FilterStatus{State: StateDisabled})
127		return nil
128	}
129
130	log.Debug().Msg("filter manager initialized (filters will load after GTK starts)")
131	m.setStatus(FilterStatus{State: StateUninitialized, Message: "Filters pending"})
132	return nil
133}
134
135// LoadAsync loads or downloads filters in the background.
136// Call this after the browser window is shown (GTK main loop running).
137// First tries to load from cache, then downloads if needed.
138func (m *Manager) LoadAsync(ctx context.Context) {
139	if !m.enabled {
140		return
141	}
142
143	go func() {
144		log := logging.FromContext(ctx).With().
145			Str("component", "filter-manager").
146			Logger()
147
148		// If already loaded, skip
149		m.filterMu.RLock()
150		if m.filter != nil {
151			m.filterMu.RUnlock()
152			log.Debug().Msg("filters already loaded, skipping async load")
153			return
154		}
155		m.filterMu.RUnlock()
156
157		// Try to load from cache first (fast path)
158		m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
159		if m.store.HasCompiledFilter(ctx, FilterIdentifier) {
160			log.Debug().Msg("found compiled filter, loading from cache")
161			filter, err := m.store.Load(ctx, FilterIdentifier)
162			if err == nil && filter != nil {
163				m.filterMu.Lock()
164				m.filter = filter
165				m.filterMu.Unlock()
166
167				version := m.getCachedVersion()
168				m.setStatus(FilterStatus{
169					State:   StateActive,
170					Message: "Filters active",
171					Version: version,
172				})
173				log.Info().Str("version", version).Msg("filters loaded from cache")
174
175				m.checkStaleCacheAndUpdate(ctx)
176				return
177			}
178			log.Warn().Err(err).Msg("failed to load cached filter, will download")
179		}
180
181		// Download filters
182		m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."})
183		paths, err := m.downloader.DownloadFilters(ctx, func(p DownloadProgress) {
184			msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total)
185			m.setStatus(FilterStatus{State: StateLoading, Message: msg})
186		})
187		if err != nil {
188			log.Error().Err(err).Msg("failed to download filters")
189			m.setStatus(FilterStatus{State: StateError, Message: "Download failed"})
190			return
191		}
192
193		m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})
194
195		// Merge JSON files into one for compilation
196		mergedPath, err := m.mergeJSONFiles(ctx, paths)
197		if err != nil {
198			log.Error().Err(err).Msg("failed to merge filter files")
199			m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"})
200			return
201		}
202
203		// Compile the merged filter
204		filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
205		if err != nil {
206			log.Error().Err(err).Msg("failed to compile filters")
207			m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
208			return
209		}
210
211		m.filterMu.Lock()
212		m.filter = filter
213		m.filterMu.Unlock()
214
215		version := m.getCachedVersion()
216		m.setStatus(FilterStatus{
217			State:   StateActive,
218			Message: "Filters active",
219			Version: version,
220		})
221		log.Info().Str("version", version).Msg("filters compiled and active")
222	}()
223}
224
225// mergeJSONFiles combines multiple JSON rule files into one.
226// WebKit's UserContentFilterStore expects a single JSON array.
227func (m *Manager) mergeJSONFiles(ctx context.Context, paths []string) (string, error) {
228	log := logging.FromContext(ctx).With().
229		Str("component", "filter-manager").
230		Logger()
231
232	var allRules []json.RawMessage
233
234	for _, path := range paths {
235		log.Debug().Str("path", path).Msg("reading filter file")
236
237		data, err := os.ReadFile(path)
238		if err != nil {
239			return "", fmt.Errorf("failed to read %s: %w", path, err)
240		}
241
242		var rules []json.RawMessage
243		if err := json.Unmarshal(data, &rules); err != nil {
244			return "", fmt.Errorf("failed to parse %s: %w", path, err)
245		}
246
247		allRules = append(allRules, rules...)
248	}
249
250	log.Debug().Int("total_rules", len(allRules)).Msg("merging filter rules")
251
252	merged, err := json.Marshal(allRules)
253	if err != nil {
254		return "", fmt.Errorf("failed to marshal merged rules: %w", err)
255	}
256
257	// Write merged file
258	mergedPath := filepath.Join(m.jsonDir, "merged.json")
259	if err := os.WriteFile(mergedPath, merged, mergedFilePerm); err != nil {
260		return "", fmt.Errorf("failed to write merged file: %w", err)
261	}
262
263	return mergedPath, nil
264}
265
266// checkStaleCacheAndUpdate checks if the cache is stale and triggers a background update.
267func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
268	if !m.downloader.IsCacheStale(CacheMaxAge) {
269		return
270	}
271	log := logging.FromContext(ctx)
272	log.Info().Msg("filter cache is stale, checking for updates in background")
273	go func() {
274		if err := m.CheckForUpdates(ctx); err != nil {
275			log.Warn().Err(err).Msg("background filter update check failed")
276		}
277	}()
278}
279
280// getCachedVersion returns the version from the cached manifest.
281func (m *Manager) getCachedVersion() string {
282	manifest, err := m.downloader.GetCachedManifest()
283	if err != nil || manifest == nil {
284		return "unknown"
285	}
286	return manifest.Version
287}
288
289// ApplyTo adds the active filter to a WebView's UserContentManager.
290// Safe to call even if filters are not yet loaded (no-op in that case).
291func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) {
292	log := logging.FromContext(ctx).With().
293		Str("component", "filter-manager").
294		Logger()
295
296	if ucm == nil {
297		return
298	}
299
300	m.filterMu.RLock()
301	filter := m.filter
302	m.filterMu.RUnlock()
303
304	if filter != nil {
305		ucm.AddFilter(filter)
306		log.Debug().Msg("content filter applied to webview")
307	} else {
308		log.Debug().Msg("no filter available to apply (filters still loading?)")
309	}
310}
311
312// GetFilter returns the currently loaded filter, or nil if none.
313func (m *Manager) GetFilter() *webkit.UserContentFilter {
314	m.filterMu.RLock()
315	defer m.filterMu.RUnlock()
316	return m.filter
317}
318
319// CheckForUpdates checks if newer filters are available and downloads them.
320// This should be called periodically in the background.
321func (m *Manager) CheckForUpdates(ctx context.Context) error {
322	if !m.enabled || !m.autoUpdate {
323		return nil
324	}
325
326	log := logging.FromContext(ctx).With().
327		Str("component", "filter-manager").
328		Logger()
329
330	needsUpdate, err := m.downloader.NeedsUpdate(ctx)
331	if err != nil {
332		log.Warn().Err(err).Msg("failed to check for filter updates")
333		return err
334	}
335
336	if !needsUpdate {
337		log.Debug().Msg("filters are up to date")
338		return nil
339	}
340
341	log.Info().Msg("filter update available, downloading")
342
343	// Download new filters
344	paths, err := m.downloader.DownloadFilters(ctx, nil)
345	if err != nil {
346		return fmt.Errorf("failed to download filter update: %w", err)
347	}
348
349	// Merge and compile
350	mergedPath, err := m.mergeJSONFiles(ctx, paths)
351	if err != nil {
352		return fmt.Errorf("failed to merge updated filters: %w", err)
353	}
354
355	filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
356	if err != nil {
357		return fmt.Errorf("failed to compile updated filters: %w", err)
358	}
359
360	m.filterMu.Lock()
361	m.filter = filter
362	m.filterMu.Unlock()
363
364	version := m.getCachedVersion()
365	m.setStatus(FilterStatus{
366		State:   StateActive,
367		Message: fmt.Sprintf("Filters updated to %s", version),
368		Version: version,
369	})
370	log.Info().Str("version", version).Msg("filters updated successfully")
371
372	return nil
373}
374
375// Enable enables content filtering.
376func (m *Manager) Enable(ctx context.Context) {
377	log := logging.FromContext(ctx).With().
378		Str("component", "filter-manager").
379		Logger()
380
381	m.enabled = true
382	log.Info().Msg("content filtering enabled")
383
384	// Try to load filters if not already loaded
385	m.filterMu.RLock()
386	hasFilter := m.filter != nil
387	m.filterMu.RUnlock()
388
389	if !hasFilter {
390		m.LoadAsync(ctx)
391	} else {
392		m.setStatus(FilterStatus{
393			State:   StateActive,
394			Message: "Filters active",
395			Version: m.getCachedVersion(),
396		})
397	}
398}
399
400// Disable disables content filtering.
401func (m *Manager) Disable(ctx context.Context) {
402	log := logging.FromContext(ctx).With().
403		Str("component", "filter-manager").
404		Logger()
405
406	m.enabled = false
407	m.setStatus(FilterStatus{State: StateDisabled})
408	log.Info().Msg("content filtering disabled")
409}
410
411// IsEnabled returns whether content filtering is enabled.
412func (m *Manager) IsEnabled() bool {
413	return m.enabled
414}
415
416// Clear removes all compiled and cached filters.
417func (m *Manager) Clear(ctx context.Context) error {
418	log := logging.FromContext(ctx).With().
419		Str("component", "filter-manager").
420		Logger()
421
422	m.filterMu.Lock()
423	m.filter = nil
424	m.filterMu.Unlock()
425
426	if err := m.store.Remove(ctx, FilterIdentifier); err != nil {
427		log.Warn().Err(err).Msg("failed to remove compiled filter")
428	}
429
430	if err := m.downloader.ClearCache(); err != nil {
431		log.Warn().Err(err).Msg("failed to clear download cache")
432	}
433
434	m.setStatus(FilterStatus{State: StateUninitialized})
435	log.Info().Msg("filters cleared")
436	return nil
437}

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