1package webkit
2
3import (
4 "context"
5 "errors"
6 "os"
7 "strings"
8 "sync"
9 "sync/atomic"
10 "time"
11
12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/puregotk/v4/glib"
14 "github.com/bnema/puregotk/v4/webkit"
15)
16
17// ErrPoolClosed is returned when operations are attempted on a closed pool.
18var ErrPoolClosed = errors.New("webview pool is closed")
19
20// FilterApplier applies content filters to a UserContentManager.
21// This interface decouples the pool from the filtering package.
22type FilterApplier interface {
23 ApplyTo(ctx context.Context, ucm *webkit.UserContentManager)
24}
25
26// PoolConfig configures the WebView pool behavior.
27type PoolConfig struct {
28 // MinSize is the minimum number of warm WebViews to maintain.
29 MinSize int
30 // MaxSize is the maximum number of WebViews to keep in the pool.
31 MaxSize int
32 // PrewarmCount is the number of WebViews to pre-create on startup.
33 PrewarmCount int
34 // IdleTimeout is how long idle views stay in the pool before being destroyed.
35 IdleTimeout time.Duration
36 // ReusePolicy controls if/when released WebViews are returned to pool.
37 // Values: off, safe, aggressive.
38 ReusePolicy string
39 // TerminatePolicy controls whether Destroy() should terminate web process.
40 // Values: auto, always, never.
41 TerminatePolicy string
42}
43
44// DefaultPoolConfig returns sensible defaults for the pool.
45func DefaultPoolConfig() PoolConfig {
46 return PoolConfig{
47 MinSize: 2,
48 MaxSize: 8,
49 PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation
50 IdleTimeout: 5 * time.Minute,
51 }
52}
53
54const (
55 reusePolicyOff = "off"
56 reusePolicySafe = "safe"
57 reusePolicyAggressive = "aggressive"
58)
59
60// WebViewPool manages a pool of pre-created WebViews for fast tab creation.
61type WebViewPool struct {
62 wkCtx *WebKitContext
63 settings *SettingsManager
64 config PoolConfig
65
66 pool chan *WebView
67 poolMu sync.Mutex
68 ctxMenuMu sync.RWMutex
69 injector *ContentInjector
70 router *MessageRouter
71 filterApplier FilterApplier // Optional content filter applier
72 ctxMenu *contextMenuPipeline // Optional context menu pipeline
73
74 // Background color for WebViews (eliminates white flash)
75 bg bgColor
76
77 closed atomic.Bool
78 wg sync.WaitGroup
79
80 reusePolicy string
81 terminatePolicy string
82
83 acquireCount atomic.Uint64
84 acquirePoolHits atomic.Uint64
85 acquirePoolMisses atomic.Uint64
86 releaseCount atomic.Uint64
87 releaseReused atomic.Uint64
88 releaseDestroyed atomic.Uint64
89 releaseDropped atomic.Uint64
90 releaseTotalNanos atomic.Int64
91 acquireTotalNanos atomic.Int64
92}
93
94// NewWebViewPool creates a new WebView pool.
95func NewWebViewPool(
96 ctx context.Context,
97 wkCtx *WebKitContext,
98 settings *SettingsManager,
99 cfg PoolConfig,
100 injector *ContentInjector,
101 router *MessageRouter,
102) *WebViewPool {
103 if cfg.MaxSize <= 0 {
104 cfg.MaxSize = 8
105 }
106 if cfg.MinSize <= 0 {
107 cfg.MinSize = 2
108 }
109 if cfg.MinSize > cfg.MaxSize {
110 cfg.MinSize = cfg.MaxSize
111 }
112 cfg.ReusePolicy = resolveReusePolicy(cfg.ReusePolicy)
113 cfg.TerminatePolicy = resolveTerminatePolicy(cfg.TerminatePolicy)
114 if ctx == nil {
115 ctx = context.Background()
116 }
117
118 p := &WebViewPool{
119 wkCtx: wkCtx,
120 settings: settings,
121 config: cfg,
122 pool: make(chan *WebView, cfg.MaxSize),
123 injector: injector,
124 router: router,
125 reusePolicy: cfg.ReusePolicy,
126 terminatePolicy: cfg.TerminatePolicy,
127 }
128
129 log := logging.FromContext(ctx)
130 log.Debug().
131 Str("reuse_policy", p.reusePolicy).
132 Str("terminate_policy", p.terminatePolicy).
133 Msg("creating webview pool")
134
135 return p
136}
137
138// SetFilterApplier sets the content filter applier.
139// Filters will be applied to all newly created WebViews.
140func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
141 p.filterApplier = applier
142}
143
144// SetBackgroundColor sets the background color for newly created WebViews.
145// This color is shown before content is painted, eliminating white flash.
146func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
147 p.bg.set(r, g, b, a)
148}
149
150// SetContextMenuPipeline sets the context menu pipeline for newly created WebViews.
151func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) {
152 p.ctxMenuMu.Lock()
153 p.ctxMenu = pipeline
154 p.ctxMenuMu.Unlock()
155}
156
157// Acquire gets a WebView from the pool or creates a new one.
158func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
159 log := logging.FromContext(ctx)
160 start := time.Now()
161
162 if p.closed.Load() {
163 return nil, ErrPoolClosed
164 }
165
166 // Try to get from pool first (non-blocking)
167 select {
168 case wv := <-p.pool:
169 if wv != nil && !wv.IsDestroyed() {
170 if r, g, b, a := p.bg.get(); a > 0 {
171 wv.SetBackgroundColor(float64(r), float64(g), float64(b), float64(a))
172 }
173 wv.inner.AddCssClass("webview-themed")
174 // Keep pooled WebViews hidden until we explicitly reveal them.
175 wv.inner.SetVisible(false)
176 if shouldReapplySettingsOnAcquire(p.settings, wv) {
177 p.settings.ApplyToWebView(ctx, wv.inner)
178 }
179
180 // Ensure frontend is attached even if this WebView was pooled before injection was configured.
181 alreadyAttached := wv.FrontendAttached()
182 if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {
183 log.Warn().Err(err).Uint64("id", uint64(wv.ID())).Msg("failed to attach frontend to pooled webview")
184 }
185 // Reinjection is only for already-attached reuse after ResetForPoolReuse.
186 // First AttachFrontend already injected; avoid an immediate double inject.
187 if needsFrontendReinjectionOnAcquire(alreadyAttached) && p.injector != nil && wv.ucm != nil {
188 wv.ucm.RemoveAllScripts()
189 wv.ucm.RemoveAllStyleSheets()
190 p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
191 }
192 // Apply filters if available (may not have been applied during prewarm)
193 if p.filterApplier != nil {
194 p.filterApplier.ApplyTo(ctx, wv.ucm)
195 }
196 // Ensure context menu pipeline is set (may not have been available during prewarm)
197 p.ctxMenuMu.RLock()
198 ctxMenu := p.ctxMenu
199 p.ctxMenuMu.RUnlock()
200 if ctxMenu != nil {
201 wv.contextMenu = ctxMenu
202 if needsExplicitContextMenuSignalOnAcquire(wv, ctxMenu) {
203 wv.connectContextMenuSignal(ctxMenu)
204 }
205 }
206 wv.ReconnectSignals()
207 p.acquireCount.Add(1)
208 p.acquirePoolHits.Add(1)
209 p.acquireTotalNanos.Add(time.Since(start).Nanoseconds())
210 log.Debug().
211 Uint64("id", uint64(wv.ID())).
212 Dur("latency", time.Since(start)).
213 Int("pool_size", len(p.pool)).
214 Str("source", "pool").
215 Msg("acquired webview")
216 return wv, nil
217 }
218 // Fall through to create new if the pooled one was destroyed
219 default:
220 // Pool empty
221 }
222
223 // Pool empty or pooled view was destroyed, create new
224 log.Debug().Msg("pool empty, creating new webview")
225 wv, err := p.createWebView(ctx)
226 if err != nil {
227 return nil, err
228 }
229 p.acquireCount.Add(1)
230 p.acquirePoolMisses.Add(1)
231 p.acquireTotalNanos.Add(time.Since(start).Nanoseconds())
232
233 // Re-check after creation: if pool closed during createWebView, destroy
234 // the new WebView and return an error to avoid leaking resources.
235 if p.closed.Load() {
236 wv.DestroyWithPolicy(p.terminatePolicy)
237 return nil, ErrPoolClosed
238 }
239
240 log.Debug().
241 Uint64("id", uint64(wv.ID())).
242 Dur("latency", time.Since(start)).
243 Int("pool_size", len(p.pool)).
244 Str("source", "new").
245 Msg("acquired webview")
246 return wv, nil
247}
248
249func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuPipeline) bool {
250 return wv != nil && pipeline != nil && len(wv.signalIDs) > 0
251}
252
253// needsFrontendReinjectionOnAcquire is true when AttachFrontend already succeeded
254// earlier, so pool acquire must wipe and reinject after reuse. First-time attach
255// injects inside AttachFrontend and must not immediately reinject.
256func needsFrontendReinjectionOnAcquire(alreadyAttached bool) bool {
257 return alreadyAttached
258}
259
260func shouldReapplySettingsOnAcquire(settings *SettingsManager, wv *WebView) bool {
261 return settings != nil && wv != nil && wv.inner != nil
262}
263
264func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
265 log := logging.FromContext(ctx)
266
267 wv, err := NewWebView(ctx, p.wkCtx, p.settings, p.bg.toGdkRGBA())
268 if err != nil {
269 return nil, err
270 }
271
272 // Add CSS class for theme background styling (prevents white flash)
273 wv.inner.AddCssClass("webview-themed")
274
275 // Keep WebView hidden until content is painted (see content.go:onLoadCommitted)
276 wv.inner.SetVisible(false)
277
278 if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {
279 log.Warn().Err(err).Msg("failed to attach frontend to new webview")
280 }
281
282 // Apply content filters if configured
283 if p.filterApplier != nil {
284 p.filterApplier.ApplyTo(ctx, wv.ucm)
285 }
286
287 // Wire context menu if configured
288 p.ctxMenuMu.RLock()
289 ctxMenu := p.ctxMenu
290 p.ctxMenuMu.RUnlock()
291 if ctxMenu != nil {
292 wv.contextMenu = ctxMenu
293 wv.connectContextMenuSignal(ctxMenu)
294 }
295
296 return wv, nil
297}
298
299// Release returns a WebView to the pool when configured and safe.
300// Default behavior remains conservative (`reuse_policy=off`) and destroys on release.
301func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
302 log := logging.FromContext(ctx)
303 start := time.Now()
304
305 if wv == nil || wv.IsDestroyed() || p.closed.Load() {
306 if wv != nil && !wv.IsDestroyed() {
307 wv.DestroyWithPolicy(p.terminatePolicy)
308 }
309 p.releaseDropped.Add(1)
310 return
311 }
312
313 action := "destroyed"
314 if p.shouldReuseOnRelease(wv) {
315 wv.ResetForPoolReuse()
316 if p.tryPut(ctx, wv) {
317 action = "reused"
318 p.releaseReused.Add(1)
319 } else {
320 action = "destroyed_after_reuse_failed"
321 wv.DestroyWithPolicy(p.terminatePolicy)
322 p.releaseDestroyed.Add(1)
323 }
324 } else {
325 wv.DestroyWithPolicy(p.terminatePolicy)
326 p.releaseDestroyed.Add(1)
327 }
328 p.releaseCount.Add(1)
329 elapsed := time.Since(start)
330 p.releaseTotalNanos.Add(elapsed.Nanoseconds())
331 log.Debug().
332 Uint64("id", uint64(wv.ID())).
333 Str("action", action).
334 Str("reuse_policy", p.reusePolicy).
335 Str("terminate_policy", p.terminatePolicy).
336 Dur("latency", elapsed).
337 Int("pool_size", len(p.pool)).
338 Uint64("acquire_total", p.acquireCount.Load()).
339 Uint64("acquire_hits", p.acquirePoolHits.Load()).
340 Uint64("acquire_misses", p.acquirePoolMisses.Load()).
341 Uint64("release_total", p.releaseCount.Load()).
342 Uint64("release_reused", p.releaseReused.Load()).
343 Uint64("release_destroyed", p.releaseDestroyed.Load()).
344 Msg("released webview")
345}
346
347// PrewarmFirst creates exactly one WebView synchronously.
348// Call this during startup (before GTK activate) to ensure first Acquire() is instant.
349// This is the most impactful optimization for cold start since WebView creation
350// is the heaviest operation (spawns WebKit web process).
351// Returns error if WebView creation fails; caller should log and continue.
352func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
353 if p.closed.Load() {
354 return ErrPoolClosed
355 }
356 if len(p.pool) > 0 {
357 return nil // Already have at least one
358 }
359
360 log := logging.FromContext(ctx)
361 wv, err := p.createWebView(ctx)
362 if err != nil {
363 return err
364 }
365
366 if p.tryPut(ctx, wv) {
367 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")
368 return nil
369 }
370 wv.DestroyWithPolicy(p.terminatePolicy) // Pool somehow full (shouldn't happen)
371 return nil
372}
373
374// Prewarm creates WebViews synchronously to populate the pool.
375// Must be called from the GTK main thread (after GTK application is initialized).
376func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
377 if count <= 0 {
378 count = p.config.PrewarmCount
379 }
380 if count <= 0 {
381 return
382 }
383 p.prewarmSync(ctx, count)
384}
385
386// PrewarmAsync schedules WebView creation on the GTK idle loop.
387// This avoids blocking startup (especially cold-start navigation) while still
388// warming up WebViews for subsequent tab creation.
389func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
390 log := logging.FromContext(ctx)
391
392 if count <= 0 {
393 count = p.config.PrewarmCount
394 }
395 if count <= 0 {
396 return
397 }
398 if p.closed.Load() {
399 return
400 }
401
402 log.Debug().Int("count", count).Int("pool_size", len(p.pool)).Msg("scheduling async webview pool prewarm")
403
404 p.wg.Add(1)
405 var doneOnce sync.Once
406 remaining := count
407
408 var schedule func()
409 schedule = func() {
410 // Stop early if the caller canceled the operation.
411 if err := ctx.Err(); err != nil {
412 log.Debug().Err(err).Msg("async webview pool prewarm canceled")
413 doneOnce.Do(p.wg.Done)
414 return
415 }
416
417 cb := glib.SourceFunc(func(_ uintptr) bool {
418 // Stop if the caller canceled the operation.
419 if err := ctx.Err(); err != nil {
420 log.Debug().Err(err).Msg("async webview pool prewarm canceled")
421 doneOnce.Do(p.wg.Done)
422 return false
423 }
424
425 // Stop if pool is closing/closed.
426 if p.closed.Load() {
427 doneOnce.Do(p.wg.Done)
428 return false
429 }
430
431 // Stop early if we reached capacity.
432 if len(p.pool) >= p.config.MaxSize {
433 doneOnce.Do(p.wg.Done)
434 return false
435 }
436
437 wv, err := p.createWebView(ctx)
438 if err != nil {
439 log.Warn().Err(err).Msg("failed to prewarm webview")
440 } else {
441 if p.tryPut(ctx, wv) {
442 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
443 } else {
444 wv.DestroyWithPolicy(p.terminatePolicy)
445 }
446 }
447
448 remaining--
449 if remaining <= 0 {
450 log.Debug().Int("pool_size", len(p.pool)).Msg("async webview pool prewarm complete")
451 doneOnce.Do(p.wg.Done)
452 return false
453 }
454
455 schedule()
456 return false
457 })
458 // Use LOW priority so initial navigation and other high-priority work runs first
459 glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil)
460 }
461
462 schedule()
463}
464
465// prewarmSync creates WebViews synchronously.
466func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
467 log := logging.FromContext(ctx)
468
469 for i := 0; i < count && !p.closed.Load(); i++ {
470 // Check if pool is already at capacity
471 if len(p.pool) >= p.config.MaxSize {
472 break
473 }
474
475 wv, err := p.createWebView(ctx)
476 if err != nil {
477 log.Warn().Err(err).Msg("failed to prewarm webview")
478 continue
479 }
480
481 // Try to add to pool
482 if p.tryPut(ctx, wv) {
483 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
484 } else {
485 // Pool full, destroy
486 wv.DestroyWithPolicy(p.terminatePolicy)
487 }
488
489 // Small delay between creations to avoid overwhelming the system
490 time.Sleep(50 * time.Millisecond)
491 }
492}
493
494// Size returns the current number of WebViews in the pool.
495func (p *WebViewPool) Size() int {
496 return len(p.pool)
497}
498
499// RefreshScripts clears and re-injects scripts to all pooled WebViews.
500// This is needed after adw.Init() to update dark mode preference that was
501// injected with the wrong value during early prewarming.
502func (p *WebViewPool) RefreshScripts(ctx context.Context) {
503 if p.closed.Load() || p.injector == nil {
504 return
505 }
506
507 log := logging.FromContext(ctx)
508
509 // Drain and refresh each WebView, then put it back
510 count := len(p.pool)
511 if count == 0 {
512 return
513 }
514
515 refreshed := 0
516refreshLoop:
517 for range count {
518 select {
519 case wv := <-p.pool:
520 if wv == nil || wv.IsDestroyed() {
521 continue
522 }
523
524 // Clear existing scripts and re-inject with updated values
525 if wv.ucm != nil {
526 wv.ucm.RemoveAllScripts()
527 wv.ucm.RemoveAllStyleSheets()
528 p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
529 }
530
531 // Put back in pool
532 if p.tryPut(ctx, wv) {
533 refreshed++
534 } else {
535 wv.DestroyWithPolicy(p.terminatePolicy)
536 }
537 default:
538 // Pool was modified during iteration, stop
539 break refreshLoop
540 }
541 }
542
543 log.Debug().
544 Int("refreshed", refreshed).
545 Bool("prefers_dark", p.injector.PrefersDark()).
546 Msg("refreshed scripts in pooled webviews")
547}
548
549// Close shuts down the pool and destroys all pooled WebViews.
550func (p *WebViewPool) Close(ctx context.Context) {
551 if p.closed.Swap(true) {
552 return // Already closed
553 }
554
555 log := logging.FromContext(ctx)
556
557 // Wait for any background operations
558 p.wg.Wait()
559
560 // Drain and destroy all pooled views
561 p.poolMu.Lock()
562 close(p.pool)
563 p.poolMu.Unlock()
564 for wv := range p.pool {
565 if wv != nil && !wv.IsDestroyed() {
566 wv.Destroy()
567 }
568 }
569
570 log.Debug().Msg("webview pool closed")
571}
572
573func (p *WebViewPool) shouldReuseOnRelease(wv *WebView) bool {
574 if p.reusePolicy == reusePolicyOff || wv == nil || wv.IsDestroyed() || wv.IsRelated() {
575 return false
576 }
577 if len(p.pool) >= p.config.MaxSize {
578 return false
579 }
580 switch p.reusePolicy {
581 case reusePolicySafe:
582 // Safe mode: only reuse views that haven't navigated, avoiding
583 // back/forward/page context leaking across tabs.
584 return !wv.HasNavigationActivity()
585 case reusePolicyAggressive:
586 // Aggressive mode: reuse regardless of navigation activity.
587 // The view is reset via ResetForPoolReuse before being returned to the pool.
588 return true
589 default:
590 return false
591 }
592}
593
594func (p *WebViewPool) tryPut(ctx context.Context, wv *WebView) bool {
595 log := logging.FromContext(ctx)
596 p.poolMu.Lock()
597 defer p.poolMu.Unlock()
598 if p.closed.Load() {
599 return false
600 }
601 select {
602 case p.pool <- wv:
603 log.Debug().Uint64("id", uint64(wv.ID())).Int("pool_size", len(p.pool)).Msg("returned webview to pool")
604 return true
605 default:
606 return false
607 }
608}
609
610func resolveReusePolicy(raw string) string {
611 switch strings.ToLower(strings.TrimSpace(defaultIfEmpty(raw, os.Getenv("DUMBER_WEBVIEW_REUSE_POLICY")))) {
612 case reusePolicySafe:
613 return reusePolicySafe
614 case reusePolicyAggressive:
615 return reusePolicyAggressive
616 case reusePolicyOff, "":
617 return reusePolicyOff
618 default:
619 return reusePolicyOff
620 }
621}
622
623func defaultIfEmpty(value, fallback string) string {
624 if strings.TrimSpace(value) != "" {
625 return value
626 }
627 return fallback
628}