1package filtering
2
3import (
4 "context"
5 "fmt"
6 "slices"
7 "sync"
8
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/puregotk/v4/gio"
11 "github.com/bnema/puregotk/v4/webkit"
12)
13
14// Store wraps webkit.UserContentFilterStore with Go-friendly async operations.
15type Store struct {
16 inner *webkit.UserContentFilterStore
17 path string
18 mu sync.Mutex
19}
20
21// NewStore creates a new Store at the given path.
22// The path is where WebKit will store compiled filter bytecode.
23func NewStore(storagePath string) *Store {
24 inner := webkit.NewUserContentFilterStore(storagePath)
25 if inner == nil {
26 return nil
27 }
28 return &Store{
29 inner: inner,
30 path: storagePath,
31 }
32}
33
34// Path returns the storage path for compiled filters.
35func (s *Store) Path() string {
36 return s.path
37}
38
39// CompileResult holds the result of a filter compilation.
40type CompileResult struct {
41 Filter *webkit.UserContentFilter
42 Err error
43}
44
45// Compile compiles a JSON filter file and stores it with the given identifier.
46// This is an async operation that may take several seconds for large filter sets.
47// The JSON file must be in Safari Content Blocker format.
48func (s *Store) Compile(ctx context.Context, identifier, jsonPath string) (*webkit.UserContentFilter, error) {
49 log := logging.FromContext(ctx).With().
50 Str("component", "filter-store").
51 Str("identifier", identifier).
52 Str("json_path", jsonPath).
53 Logger()
54
55 s.mu.Lock()
56 defer s.mu.Unlock()
57
58 // Create GFile from path
59 file := gio.FileNewForPath(jsonPath)
60 if file == nil {
61 return nil, fmt.Errorf("failed to create GFile for path: %s", jsonPath)
62 }
63
64 log.Debug().Msg("starting filter compilation")
65
66 // Use a channel to receive the async result
67 resultCh := make(chan CompileResult, 1)
68
69 // Create async callback
70 cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {
71 if resPtr == 0 {
72 resultCh <- CompileResult{Err: fmt.Errorf("nil async result")}
73 return
74 }
75
76 res := &gio.AsyncResultBase{Ptr: resPtr}
77 filter, err := s.inner.SaveFromFileFinish(res)
78 resultCh <- CompileResult{Filter: filter, Err: err}
79 })
80
81 // Start async compilation
82 s.inner.SaveFromFile(identifier, file, nil, &cb, 0)
83
84 // Wait for result or context cancellation
85 select {
86 case result := <-resultCh:
87 if result.Err != nil {
88 log.Error().Err(result.Err).Msg("filter compilation failed")
89 return nil, result.Err
90 }
91 log.Info().Msg("filter compilation completed")
92 return result.Filter, nil
93 case <-ctx.Done():
94 log.Warn().Msg("filter compilation canceled")
95 return nil, ctx.Err()
96 }
97}
98
99// LoadResult holds the result of loading a compiled filter.
100type LoadResult struct {
101 Filter *webkit.UserContentFilter
102 Err error
103}
104
105// Load loads a previously compiled filter by its identifier.
106// This is fast (~50ms) as it loads pre-compiled bytecode.
107func (s *Store) Load(ctx context.Context, identifier string) (*webkit.UserContentFilter, error) {
108 log := logging.FromContext(ctx).With().
109 Str("component", "filter-store").
110 Str("identifier", identifier).
111 Logger()
112
113 s.mu.Lock()
114 defer s.mu.Unlock()
115
116 log.Debug().Msg("loading compiled filter")
117
118 // Use a channel to receive the async result
119 resultCh := make(chan LoadResult, 1)
120
121 // Create async callback
122 cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {
123 if resPtr == 0 {
124 resultCh <- LoadResult{Err: fmt.Errorf("nil async result")}
125 return
126 }
127
128 res := &gio.AsyncResultBase{Ptr: resPtr}
129 filter, err := s.inner.LoadFinish(res)
130 resultCh <- LoadResult{Filter: filter, Err: err}
131 })
132
133 // Start async load
134 s.inner.Load(identifier, nil, &cb, 0)
135
136 // Wait for result or context cancellation
137 select {
138 case result := <-resultCh:
139 if result.Err != nil {
140 log.Debug().Err(result.Err).Msg("filter load failed (may not exist)")
141 return nil, result.Err
142 }
143 log.Debug().Msg("filter loaded successfully")
144 return result.Filter, nil
145 case <-ctx.Done():
146 log.Warn().Msg("filter load canceled")
147 return nil, ctx.Err()
148 }
149}
150
151// FetchIdentifiersResult holds the result of fetching filter identifiers.
152type FetchIdentifiersResult struct {
153 Identifiers []string
154 Err error
155}
156
157// FetchIdentifiers returns all stored filter identifiers.
158func (s *Store) FetchIdentifiers(ctx context.Context) ([]string, error) {
159 log := logging.FromContext(ctx).With().
160 Str("component", "filter-store").
161 Logger()
162
163 s.mu.Lock()
164 defer s.mu.Unlock()
165
166 log.Debug().Msg("fetching filter identifiers")
167
168 // Use a channel to receive the async result
169 resultCh := make(chan FetchIdentifiersResult, 1)
170
171 // Create async callback
172 cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {
173 if resPtr == 0 {
174 resultCh <- FetchIdentifiersResult{Err: fmt.Errorf("nil async result")}
175 return
176 }
177
178 res := &gio.AsyncResultBase{Ptr: resPtr}
179 identifiers := s.inner.FetchIdentifiersFinish(res)
180 resultCh <- FetchIdentifiersResult{Identifiers: identifiers}
181 })
182
183 // Start async fetch
184 s.inner.FetchIdentifiers(nil, &cb, 0)
185
186 // Wait for result or context cancellation
187 select {
188 case result := <-resultCh:
189 if result.Err != nil {
190 log.Error().Err(result.Err).Msg("failed to fetch identifiers")
191 return nil, result.Err
192 }
193 log.Debug().Int("count", len(result.Identifiers)).Msg("fetched filter identifiers")
194 return result.Identifiers, nil
195 case <-ctx.Done():
196 log.Warn().Msg("fetch identifiers canceled")
197 return nil, ctx.Err()
198 }
199}
200
201// Remove removes a compiled filter by its identifier.
202func (s *Store) Remove(ctx context.Context, identifier string) error {
203 log := logging.FromContext(ctx).With().
204 Str("component", "filter-store").
205 Str("identifier", identifier).
206 Logger()
207
208 s.mu.Lock()
209 defer s.mu.Unlock()
210
211 log.Debug().Msg("removing filter")
212
213 // Use a channel to receive the async result
214 resultCh := make(chan error, 1)
215
216 // Create async callback
217 cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {
218 if resPtr == 0 {
219 resultCh <- fmt.Errorf("nil async result")
220 return
221 }
222
223 res := &gio.AsyncResultBase{Ptr: resPtr}
224 _, err := s.inner.RemoveFinish(res)
225 resultCh <- err
226 })
227
228 // Start async remove
229 s.inner.Remove(identifier, nil, &cb, 0)
230
231 // Wait for result or context cancellation
232 select {
233 case err := <-resultCh:
234 if err != nil {
235 log.Error().Err(err).Msg("failed to remove filter")
236 return err
237 }
238 log.Info().Msg("filter removed")
239 return nil
240 case <-ctx.Done():
241 log.Warn().Msg("remove filter canceled")
242 return ctx.Err()
243 }
244}
245
246// HasCompiledFilter checks if a compiled filter exists for the given identifier.
247func (s *Store) HasCompiledFilter(ctx context.Context, identifier string) bool {
248 identifiers, err := s.FetchIdentifiers(ctx)
249 if err != nil {
250 return false
251 }
252 return slices.Contains(identifiers, identifier)
253}