1package cef
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "strconv"
8 "strings"
9 "sync"
10 "sync/atomic"
11 "syscall"
12
13 "github.com/bnema/dumber/internal/application/port"
14 "github.com/bnema/dumber/internal/infrastructure/config"
15 "github.com/bnema/dumber/internal/logging"
16 "github.com/bnema/dumber/internal/shared/syncdispatch"
17)
18
19const (
20 accessibilityCaptureQueueCapacity = 64
21 accessibilityCaptureDirPerm = 0o700
22 accessibilityCaptureFilePerm = 0o600
23 accessibilityCaptureEnvName = "DUMBER_A11Y_CAPTURE"
24 accessibilityCaptureEnvValue = "1"
25 accessibilityCaptureKindTree = "tree"
26 accessibilityCaptureKindLocation = "location"
27
28 // Per-WebView capture budgets for the durable .../a11y-capture/webview-%d
29 // namespace. Sized to cover an ~80s measurement campaign with headroom for
30 // frequent tree+location updates, while capping unbounded growth when the
31 // same WebView-ID directory is reused across sessions. Existing strict
32 // capture files count toward both budgets; writes reject before create
33 // when either limit would be exceeded. Tests may inject smaller limits.
34 accessibilityCaptureDefaultMaxFiles = 10_000
35 accessibilityCaptureDefaultMaxBytes = 512 << 20 // 512 MiB
36)
37
38type accessibilityStatsSnapshot struct {
39 Count int
40 TreeCount int
41 LocationCount int
42 TotalBytes int64
43 MinBytes int
44 MaxBytes int
45 AverageBytes float64
46 TotalSerializeNanos int64
47 MaxSerializeNanos int64
48 AverageSerializeNanos float64
49}
50
51type accessibilityStats struct {
52 mu sync.Mutex
53 count int
54 treeCount int
55 locationCount int
56 totalBytes int64
57 minBytes int
58 maxBytes int
59 totalSerializeNanos int64
60 maxSerializeNanos int64
61}
62
63func (s *accessibilityStats) Observe(p accessibilityPayload) {
64 if s == nil {
65 return
66 }
67 s.mu.Lock()
68 defer s.mu.Unlock()
69 s.count++
70 switch p.Kind {
71 case accessibilityCaptureKindTree:
72 s.treeCount++
73 case accessibilityCaptureKindLocation:
74 s.locationCount++
75 }
76 s.totalBytes += int64(p.Bytes)
77 if s.count == 1 || p.Bytes < s.minBytes {
78 s.minBytes = p.Bytes
79 }
80 if p.Bytes > s.maxBytes {
81 s.maxBytes = p.Bytes
82 }
83 s.totalSerializeNanos += p.SerializeNanos
84 if p.SerializeNanos > s.maxSerializeNanos {
85 s.maxSerializeNanos = p.SerializeNanos
86 }
87}
88
89func (s *accessibilityStats) Snapshot() accessibilityStatsSnapshot {
90 if s == nil {
91 return accessibilityStatsSnapshot{}
92 }
93 s.mu.Lock()
94 defer s.mu.Unlock()
95 if s.count == 0 {
96 return accessibilityStatsSnapshot{}
97 }
98 return accessibilityStatsSnapshot{
99 Count: s.count,
100 TreeCount: s.treeCount,
101 LocationCount: s.locationCount,
102 TotalBytes: s.totalBytes,
103 MinBytes: s.minBytes,
104 MaxBytes: s.maxBytes,
105 AverageBytes: float64(s.totalBytes) / float64(s.count),
106 TotalSerializeNanos: s.totalSerializeNanos,
107 MaxSerializeNanos: s.maxSerializeNanos,
108 AverageSerializeNanos: float64(s.totalSerializeNanos) / float64(s.count),
109 }
110}
111
112type accessibilityCaptureWorker struct {
113 mu sync.RWMutex
114 closed bool
115 queue chan accessibilityPayload
116 wg sync.WaitGroup
117 consume func(accessibilityPayload)
118 dropped atomic.Uint64
119}
120
121func newAccessibilityCaptureWorker(capacity int, consume func(accessibilityPayload)) *accessibilityCaptureWorker {
122 if capacity < 1 {
123 capacity = 1
124 }
125 w := &accessibilityCaptureWorker{
126 queue: make(chan accessibilityPayload, capacity),
127 consume: consume,
128 }
129 w.wg.Add(1)
130 go w.loop()
131 return w
132}
133
134func (w *accessibilityCaptureWorker) loop() {
135 defer w.wg.Done()
136 for p := range w.queue {
137 if w.consume != nil {
138 w.consume(p)
139 }
140 }
141}
142
143func (w *accessibilityCaptureWorker) Submit(p accessibilityPayload) bool {
144 if w == nil {
145 return false
146 }
147 w.mu.RLock()
148 defer w.mu.RUnlock()
149 if w.closed {
150 return false
151 }
152 select {
153 case w.queue <- p:
154 return true
155 default:
156 w.dropped.Add(1)
157 return false
158 }
159}
160
161func (w *accessibilityCaptureWorker) Dropped() uint64 {
162 if w == nil {
163 return 0
164 }
165 return w.dropped.Load()
166}
167
168func (w *accessibilityCaptureWorker) Close() {
169 if w == nil {
170 return
171 }
172 w.mu.Lock()
173 if w.closed {
174 w.mu.Unlock()
175 return
176 }
177 w.closed = true
178 close(w.queue)
179 w.mu.Unlock()
180 w.wg.Wait()
181}
182
183type accessibilityCapture struct {
184 mu sync.Mutex
185 dir string
186 seq uint64
187 fileCount int
188 totalBytes int64
189 maxFiles int
190 maxBytes int64
191}
192
193func accessibilityCaptureEnabled() bool {
194 return os.Getenv(accessibilityCaptureEnvName) == accessibilityCaptureEnvValue
195}
196
197func accessibilityCaptureDir(webViewID port.WebViewID) (string, error) {
198 logDir, err := config.GetLogDir()
199 if err != nil {
200 return "", err
201 }
202 return filepath.Join(logDir, "a11y-capture", fmt.Sprintf("webview-%d", webViewID)), nil
203}
204
205func validateAccessibilityCaptureDir(dir string) error {
206 if dir == "" || dir == "." {
207 return fmt.Errorf("accessibility capture dir rejected: %q", dir)
208 }
209 for _, part := range strings.Split(filepath.ToSlash(dir), "/") {
210 if part == ".." {
211 return fmt.Errorf("accessibility capture dir rejected: %q", dir)
212 }
213 }
214 return nil
215}
216
217// parseAccessibilityCaptureName accepts only strict numbered capture names
218// produced by Write: NNNNNN-(tree|location).json with a non-zero sequence.
219func parseAccessibilityCaptureName(name string) (seq uint64, ok bool) {
220 if !strings.HasSuffix(name, ".json") {
221 return 0, false
222 }
223 base := strings.TrimSuffix(name, ".json")
224 seqPart, kind, cut := strings.Cut(base, "-")
225 if !cut || len(seqPart) != 6 || !accessibilityCaptureKindAllowed(kind) {
226 return 0, false
227 }
228 for _, r := range seqPart {
229 if r < '0' || r > '9' {
230 return 0, false
231 }
232 }
233 n, err := strconv.ParseUint(seqPart, 10, 64)
234 if err != nil || n == 0 {
235 return 0, false
236 }
237 return n, true
238}
239
240// scanAccessibilityCaptureDir accounts only strict regular capture files.
241// Symlinks and non-regular entries are ignored (never followed). Gaps are
242// allowed; the returned sequence is the maximum numbered name present.
243func scanAccessibilityCaptureDir(dir string) (maxSeq uint64, fileCount int, totalBytes int64, err error) {
244 entries, err := os.ReadDir(dir)
245 if err != nil {
246 return 0, 0, 0, fmt.Errorf("read accessibility capture dir: %w", err)
247 }
248 for _, entry := range entries {
249 name := entry.Name()
250 seq, ok := parseAccessibilityCaptureName(name)
251 if !ok {
252 continue
253 }
254 path := filepath.Join(dir, name)
255 info, lstatErr := os.Lstat(path)
256 if lstatErr != nil {
257 return 0, 0, 0, fmt.Errorf("lstat accessibility capture %s: %w", name, lstatErr)
258 }
259 if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
260 continue
261 }
262 fileCount++
263 totalBytes += info.Size()
264 if seq > maxSeq {
265 maxSeq = seq
266 }
267 }
268 return maxSeq, fileCount, totalBytes, nil
269}
270
271func newAccessibilityCapture(dir string) (*accessibilityCapture, error) {
272 if err := validateAccessibilityCaptureDir(dir); err != nil {
273 return nil, err
274 }
275 if err := os.MkdirAll(dir, accessibilityCaptureDirPerm); err != nil {
276 return nil, fmt.Errorf("mkdir accessibility capture dir: %w", err)
277 }
278 if err := os.Chmod(dir, accessibilityCaptureDirPerm); err != nil {
279 return nil, fmt.Errorf("chmod accessibility capture dir: %w", err)
280 }
281 maxSeq, fileCount, totalBytes, err := scanAccessibilityCaptureDir(dir)
282 if err != nil {
283 return nil, err
284 }
285 return &accessibilityCapture{
286 dir: dir,
287 seq: maxSeq,
288 fileCount: fileCount,
289 totalBytes: totalBytes,
290 maxFiles: accessibilityCaptureDefaultMaxFiles,
291 maxBytes: accessibilityCaptureDefaultMaxBytes,
292 }, nil
293}
294
295func accessibilityCaptureKindAllowed(kind string) bool {
296 switch kind {
297 case accessibilityCaptureKindTree, accessibilityCaptureKindLocation:
298 return true
299 default:
300 return false
301 }
302}
303
304func (c *accessibilityCapture) Write(p accessibilityPayload) error {
305 if c == nil || p.JSON == "" {
306 return nil
307 }
308 if !accessibilityCaptureKindAllowed(p.Kind) {
309 return fmt.Errorf("accessibility capture kind rejected: %q", p.Kind)
310 }
311 payloadBytes := int64(len(p.JSON))
312 c.mu.Lock()
313 defer c.mu.Unlock()
314 if c.fileCount >= c.maxFiles {
315 return fmt.Errorf("accessibility capture file budget exceeded (%d files)", c.maxFiles)
316 }
317 if c.totalBytes+payloadBytes > c.maxBytes {
318 return fmt.Errorf("accessibility capture byte budget exceeded (%d bytes)", c.maxBytes)
319 }
320 next := c.seq + 1
321 name := fmt.Sprintf("%06d-%s.json", next, p.Kind)
322 path := filepath.Join(c.dir, name)
323 file, err := openFileNoFollow(path, syscall.O_CREAT|syscall.O_EXCL|syscall.O_WRONLY, accessibilityCaptureFilePerm)
324 if err != nil {
325 return err
326 }
327 c.seq = next
328 c.fileCount++
329 written, writeErr := file.WriteString(p.JSON)
330 c.totalBytes += int64(written)
331 chmodErr := file.Chmod(accessibilityCaptureFilePerm)
332 closeErr := file.Close()
333 if writeErr != nil {
334 return writeErr
335 }
336 if chmodErr != nil {
337 return chmodErr
338 }
339 return closeErr
340}
341
342func (wv *WebView) enableAccessibilityCaptureIfRequested() error {
343 if wv == nil || !accessibilityCaptureEnabled() {
344 return nil
345 }
346 dir, err := accessibilityCaptureDir(wv.id)
347 if err != nil {
348 return err
349 }
350 capture, err := newAccessibilityCapture(dir)
351 if err != nil {
352 return err
353 }
354 wv.a11yCapture = capture
355 wv.a11yWorker = newAccessibilityCaptureWorker(accessibilityCaptureQueueCapacity, wv.consumeAccessibilityPayload)
356 logAccessibilityCaptureEnabled(wv.ctx, wv.id, dir)
357 return nil
358}
359
360func (wv *WebView) consumeAccessibilityPayload(p accessibilityPayload) {
361 if wv == nil {
362 return
363 }
364 wv.a11yStats.Observe(p)
365 logAccessibilityUpdate(wv.ctx, wv.id, p)
366 if wv.a11yCapture == nil {
367 return
368 }
369 if err := wv.a11yCapture.Write(p); err != nil {
370 logging.FromContext(wv.ctx).Warn().Err(err).
371 Uint64("webview_id", uint64(wv.id)).
372 Msg("cef: accessibility capture write failed")
373 }
374}
375
376// abortAccessibilityCaptureSetup closes a capture worker started during
377// newWebView without emitting Destroy's summary. Safe to call when unset.
378func (wv *WebView) abortAccessibilityCaptureSetup() {
379 wv.finalizeAccessibilityCapture(false)
380}
381
382// failNewWebViewAfterAccessibilityCapture centralizes cleanup for every
383// newWebView error return after enableAccessibilityCaptureIfRequested may have
384// started the worker. Closes the worker exactly once without Destroy summary.
385func (wv *WebView) failNewWebViewAfterAccessibilityCapture(
386 operation string,
387 result syncdispatch.SyncDispatchResult,
388) error {
389 wv.abortAccessibilityCaptureSetup()
390 wv.destroyViewBridgeOnGTKAsync()
391 return errGTKSyncDispatchIncomplete(operation, result)
392}
393
394// newWebViewInstallViewportSyncHooks is the viewport-hook install used by
395// newWebView. Tests replace it to force incomplete GTK dispatch after capture
396// init without a live display.
397var newWebViewInstallViewportSyncHooks = func(wv *WebView) syncdispatch.SyncDispatchResult {
398 return wv.installViewportSyncHooks()
399}
400
401// installNewWebViewViewportHooksAfterCapture installs viewport sync hooks and
402// aborts capture setup if GTK dispatch does not complete.
403func (wv *WebView) installNewWebViewViewportHooksAfterCapture() error {
404 result := newWebViewInstallViewportSyncHooks(wv)
405 if result.Completed() {
406 return nil
407 }
408 return wv.failNewWebViewAfterAccessibilityCapture("install CEF viewport sync hooks", result)
409}
410
411func (wv *WebView) shutdownAccessibilityCapture() {
412 wv.finalizeAccessibilityCapture(true)
413}
414
415// finalizeAccessibilityCapture claims one-shot ownership of worker close.
416// Pointers stay published so concurrent enqueueAccessibilityPayload reads are
417// race-free; Submit observes the closed worker and returns false.
418func (wv *WebView) finalizeAccessibilityCapture(emitSummary bool) {
419 if wv == nil {
420 return
421 }
422 if !wv.a11yCaptureFinalized.CompareAndSwap(false, true) {
423 return
424 }
425 worker := wv.a11yWorker
426 if worker == nil {
427 return
428 }
429 worker.Close()
430 if emitSummary {
431 logAccessibilitySummary(wv.ctx, wv.id, wv.a11yStats.Snapshot(), worker.Dropped())
432 }
433}