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