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 _ = wv.AttachFrontend(ctx, p.injector, p.router)
182 // Apply filters if available (may not have been applied during prewarm)
183 if p.filterApplier != nil {
184 p.filterApplier.ApplyTo(ctx, wv.ucm)
185 }
186 // Ensure context menu pipeline is set (may not have been available during prewarm)
187 p.ctxMenuMu.RLock()
188 ctxMenu := p.ctxMenu
189 p.ctxMenuMu.RUnlock()
190 if ctxMenu != nil {
191 wv.contextMenu = ctxMenu
192 if needsExplicitContextMenuSignalOnAcquire(wv, ctxMenu) {
193 wv.connectContextMenuSignal(ctxMenu)
194 }
195 }
196 wv.ReconnectSignals()
197 p.acquireCount.Add(1)
198 p.acquirePoolHits.Add(1)
199 p.acquireTotalNanos.Add(time.Since(start).Nanoseconds())
200 log.Debug().
201 Uint64("id", uint64(wv.ID())).
202 Dur("latency", time.Since(start)).
203 Int("pool_size", len(p.pool)).
204 Str("source", "pool").
205 Msg("acquired webview")
206 return wv, nil
207 }
208 // Fall through to create new if the pooled one was destroyed
209 default:
210 // Pool empty
211 }
212
213 // Pool empty or pooled view was destroyed, create new
214 log.Debug().Msg("pool empty, creating new webview")
215 wv, err := p.createWebView(ctx)
216 if err != nil {
217 return nil, err
218 }
219 p.acquireCount.Add(1)
220 p.acquirePoolMisses.Add(1)
221 p.acquireTotalNanos.Add(time.Since(start).Nanoseconds())
222
223 // Re-check after creation: if pool closed during createWebView, destroy
224 // the new WebView and return an error to avoid leaking resources.
225 if p.closed.Load() {
226 wv.DestroyWithPolicy(p.terminatePolicy)
227 return nil, ErrPoolClosed
228 }
229
230 log.Debug().
231 Uint64("id", uint64(wv.ID())).
232 Dur("latency", time.Since(start)).
233 Int("pool_size", len(p.pool)).
234 Str("source", "new").
235 Msg("acquired webview")
236 return wv, nil
237}
238
239func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuPipeline) bool {
240 return wv != nil && pipeline != nil && len(wv.signalIDs) > 0
241}
242
243func shouldReapplySettingsOnAcquire(settings *SettingsManager, wv *WebView) bool {
244 return settings != nil && wv != nil && wv.inner != nil
245}
246
247func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
248 log := logging.FromContext(ctx)
249
250 wv, err := NewWebView(ctx, p.wkCtx, p.settings, p.bg.toGdkRGBA())
251 if err != nil {
252 return nil, err
253 }
254
255 // Add CSS class for theme background styling (prevents white flash)
256 wv.inner.AddCssClass("webview-themed")
257
258 // Keep WebView hidden until content is painted (see content.go:onLoadCommitted)
259 wv.inner.SetVisible(false)
260
261 if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {
262 log.Warn().Err(err).Msg("failed to attach frontend to new webview")
263 }
264
265 // Apply content filters if configured
266 if p.filterApplier != nil {
267 p.filterApplier.ApplyTo(ctx, wv.ucm)
268 }
269
270 // Wire context menu if configured
271 p.ctxMenuMu.RLock()
272 ctxMenu := p.ctxMenu
273 p.ctxMenuMu.RUnlock()
274 if ctxMenu != nil {
275 wv.contextMenu = ctxMenu
276 wv.connectContextMenuSignal(ctxMenu)
277 }
278
279 return wv, nil
280}
281
282// Release returns a WebView to the pool when configured and safe.
283// Default behavior remains conservative (`reuse_policy=off`) and destroys on release.
284func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
285 log := logging.FromContext(ctx)
286 start := time.Now()
287
288 if wv == nil || wv.IsDestroyed() || p.closed.Load() {
289 if wv != nil && !wv.IsDestroyed() {
290 wv.DestroyWithPolicy(p.terminatePolicy)
291 }
292 p.releaseDropped.Add(1)
293 return
294 }
295
296 action := "destroyed"
297 if p.shouldReuseOnRelease(wv) {
298 wv.ResetForPoolReuse()
299 if p.tryPut(ctx, wv) {
300 action = "reused"
301 p.releaseReused.Add(1)
302 } else {
303 action = "destroyed_after_reuse_failed"
304 wv.DestroyWithPolicy(p.terminatePolicy)
305 p.releaseDestroyed.Add(1)
306 }
307 } else {
308 wv.DestroyWithPolicy(p.terminatePolicy)
309 p.releaseDestroyed.Add(1)
310 }
311 p.releaseCount.Add(1)
312 elapsed := time.Since(start)
313 p.releaseTotalNanos.Add(elapsed.Nanoseconds())
314 log.Debug().
315 Uint64("id", uint64(wv.ID())).
316 Str("action", action).
317 Str("reuse_policy", p.reusePolicy).
318 Str("terminate_policy", p.terminatePolicy).
319 Dur("latency", elapsed).
320 Int("pool_size", len(p.pool)).
321 Uint64("acquire_total", p.acquireCount.Load()).
322 Uint64("acquire_hits", p.acquirePoolHits.Load()).
323 Uint64("acquire_misses", p.acquirePoolMisses.Load()).
324 Uint64("release_total", p.releaseCount.Load()).
325 Uint64("release_reused", p.releaseReused.Load()).
326 Uint64("release_destroyed", p.releaseDestroyed.Load()).
327 Msg("released webview")
328}
329
330// PrewarmFirst creates exactly one WebView synchronously.
331// Call this during startup (before GTK activate) to ensure first Acquire() is instant.
332// This is the most impactful optimization for cold start since WebView creation
333// is the heaviest operation (spawns WebKit web process).
334// Returns error if WebView creation fails; caller should log and continue.
335func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
336 if p.closed.Load() {
337 return ErrPoolClosed
338 }
339 if len(p.pool) > 0 {
340 return nil // Already have at least one
341 }
342
343 log := logging.FromContext(ctx)
344 wv, err := p.createWebView(ctx)
345 if err != nil {
346 return err
347 }
348
349 if p.tryPut(ctx, wv) {
350 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")
351 return nil
352 }
353 wv.DestroyWithPolicy(p.terminatePolicy) // Pool somehow full (shouldn't happen)
354 return nil
355}
356
357// Prewarm creates WebViews synchronously to populate the pool.
358// Must be called from the GTK main thread (after GTK application is initialized).
359func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
360 if count <= 0 {
361 count = p.config.PrewarmCount
362 }
363 if count <= 0 {
364 return
365 }
366 p.prewarmSync(ctx, count)
367}
368
369// PrewarmAsync schedules WebView creation on the GTK idle loop.
370// This avoids blocking startup (especially cold-start navigation) while still
371// warming up WebViews for subsequent tab creation.
372func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
373 log := logging.FromContext(ctx)
374
375 if count <= 0 {
376 count = p.config.PrewarmCount
377 }
378 if count <= 0 {
379 return
380 }
381 if p.closed.Load() {
382 return
383 }
384
385 log.Debug().Int("count", count).Int("pool_size", len(p.pool)).Msg("scheduling async webview pool prewarm")
386
387 p.wg.Add(1)
388 var doneOnce sync.Once
389 remaining := count
390
391 var schedule func()
392 schedule = func() {
393 // Stop early if the caller canceled the operation.
394 if err := ctx.Err(); err != nil {
395 log.Debug().Err(err).Msg("async webview pool prewarm canceled")
396 doneOnce.Do(p.wg.Done)
397 return
398 }
399
400 cb := glib.SourceFunc(func(_ uintptr) bool {
401 // Stop if the caller canceled the operation.
402 if err := ctx.Err(); err != nil {
403 log.Debug().Err(err).Msg("async webview pool prewarm canceled")
404 doneOnce.Do(p.wg.Done)
405 return false
406 }
407
408 // Stop if pool is closing/closed.
409 if p.closed.Load() {
410 doneOnce.Do(p.wg.Done)
411 return false
412 }
413
414 // Stop early if we reached capacity.
415 if len(p.pool) >= p.config.MaxSize {
416 doneOnce.Do(p.wg.Done)
417 return false
418 }
419
420 wv, err := p.createWebView(ctx)
421 if err != nil {
422 log.Warn().Err(err).Msg("failed to prewarm webview")
423 } else {
424 if p.tryPut(ctx, wv) {
425 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
426 } else {
427 wv.DestroyWithPolicy(p.terminatePolicy)
428 }
429 }
430
431 remaining--
432 if remaining <= 0 {
433 log.Debug().Int("pool_size", len(p.pool)).Msg("async webview pool prewarm complete")
434 doneOnce.Do(p.wg.Done)
435 return false
436 }
437
438 schedule()
439 return false
440 })
441 // Use LOW priority so initial navigation and other high-priority work runs first
442 glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil)
443 }
444
445 schedule()
446}
447
448// prewarmSync creates WebViews synchronously.
449func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
450 log := logging.FromContext(ctx)
451
452 for i := 0; i < count && !p.closed.Load(); i++ {
453 // Check if pool is already at capacity
454 if len(p.pool) >= p.config.MaxSize {
455 break
456 }
457
458 wv, err := p.createWebView(ctx)
459 if err != nil {
460 log.Warn().Err(err).Msg("failed to prewarm webview")
461 continue
462 }
463
464 // Try to add to pool
465 if p.tryPut(ctx, wv) {
466 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
467 } else {
468 // Pool full, destroy
469 wv.DestroyWithPolicy(p.terminatePolicy)
470 }
471
472 // Small delay between creations to avoid overwhelming the system
473 time.Sleep(50 * time.Millisecond)
474 }
475}
476
477// Size returns the current number of WebViews in the pool.
478func (p *WebViewPool) Size() int {
479 return len(p.pool)
480}
481
482// RefreshScripts clears and re-injects scripts to all pooled WebViews.
483// This is needed after adw.Init() to update dark mode preference that was
484// injected with the wrong value during early prewarming.
485func (p *WebViewPool) RefreshScripts(ctx context.Context) {
486 if p.closed.Load() || p.injector == nil {
487 return
488 }
489
490 log := logging.FromContext(ctx)
491
492 // Drain and refresh each WebView, then put it back
493 count := len(p.pool)
494 if count == 0 {
495 return
496 }
497
498 refreshed := 0
499refreshLoop:
500 for i := 0; i < count; i++ {
501 select {
502 case wv := <-p.pool:
503 if wv == nil || wv.IsDestroyed() {
504 continue
505 }
506
507 // Clear existing scripts and re-inject with updated values
508 if wv.ucm != nil {
509 wv.ucm.RemoveAllScripts()
510 wv.ucm.RemoveAllStyleSheets()
511 p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
512 }
513
514 // Put back in pool
515 if p.tryPut(ctx, wv) {
516 refreshed++
517 } else {
518 wv.DestroyWithPolicy(p.terminatePolicy)
519 }
520 default:
521 // Pool was modified during iteration, stop
522 break refreshLoop
523 }
524 }
525
526 log.Debug().
527 Int("refreshed", refreshed).
528 Bool("prefers_dark", p.injector.PrefersDark()).
529 Msg("refreshed scripts in pooled webviews")
530}
531
532// Close shuts down the pool and destroys all pooled WebViews.
533func (p *WebViewPool) Close(ctx context.Context) {
534 if p.closed.Swap(true) {
535 return // Already closed
536 }
537
538 log := logging.FromContext(ctx)
539
540 // Wait for any background operations
541 p.wg.Wait()
542
543 // Drain and destroy all pooled views
544 p.poolMu.Lock()
545 close(p.pool)
546 p.poolMu.Unlock()
547 for wv := range p.pool {
548 if wv != nil && !wv.IsDestroyed() {
549 wv.Destroy()
550 }
551 }
552
553 log.Debug().Msg("webview pool closed")
554}
555
556func (p *WebViewPool) shouldReuseOnRelease(wv *WebView) bool {
557 if p.reusePolicy == reusePolicyOff || wv == nil || wv.IsDestroyed() || wv.IsRelated() {
558 return false
559 }
560 if len(p.pool) >= p.config.MaxSize {
561 return false
562 }
563 switch p.reusePolicy {
564 case reusePolicySafe:
565 // Safe mode: only reuse views that haven't navigated, avoiding
566 // back/forward/page context leaking across tabs.
567 return !wv.HasNavigationActivity()
568 case reusePolicyAggressive:
569 // Aggressive mode: reuse regardless of navigation activity.
570 // The view is reset via ResetForPoolReuse before being returned to the pool.
571 return true
572 default:
573 return false
574 }
575}
576
577func (p *WebViewPool) tryPut(ctx context.Context, wv *WebView) bool {
578 log := logging.FromContext(ctx)
579 p.poolMu.Lock()
580 defer p.poolMu.Unlock()
581 if p.closed.Load() {
582 return false
583 }
584 select {
585 case p.pool <- wv:
586 log.Debug().Uint64("id", uint64(wv.ID())).Int("pool_size", len(p.pool)).Msg("returned webview to pool")
587 return true
588 default:
589 return false
590 }
591}
592
593func resolveReusePolicy(raw string) string {
594 switch strings.ToLower(strings.TrimSpace(defaultIfEmpty(raw, os.Getenv("DUMBER_WEBVIEW_REUSE_POLICY")))) {
595 case reusePolicySafe:
596 return reusePolicySafe
597 case reusePolicyAggressive:
598 return reusePolicyAggressive
599 case reusePolicyOff, "":
600 return reusePolicyOff
601 default:
602 return reusePolicyOff
603 }
604}
605
606func defaultIfEmpty(value, fallback string) string {
607 if strings.TrimSpace(value) != "" {
608 return value
609 }
610 return fallback
611}