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