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