package webkit import ( "context" "errors" "os" "strings" "sync" "sync/atomic" "time" "github.com/bnema/dumber/internal/logging" "github.com/bnema/puregotk/v4/glib" "github.com/bnema/puregotk/v4/webkit" ) // ErrPoolClosed is returned when operations are attempted on a closed pool. var ErrPoolClosed = errors.New("webview pool is closed") // FilterApplier applies content filters to a UserContentManager. // This interface decouples the pool from the filtering package. type FilterApplier interface { ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) } // PoolConfig configures the WebView pool behavior. type PoolConfig struct { // MinSize is the minimum number of warm WebViews to maintain. MinSize int // MaxSize is the maximum number of WebViews to keep in the pool. MaxSize int // PrewarmCount is the number of WebViews to pre-create on startup. PrewarmCount int // IdleTimeout is how long idle views stay in the pool before being destroyed. IdleTimeout time.Duration // ReusePolicy controls if/when released WebViews are returned to pool. // Values: off, safe, aggressive. ReusePolicy string // TerminatePolicy controls whether Destroy() should terminate web process. // Values: auto, always, never. TerminatePolicy string } // DefaultPoolConfig returns sensible defaults for the pool. func DefaultPoolConfig() PoolConfig { return PoolConfig{ MinSize: 2, MaxSize: 8, PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation IdleTimeout: 5 * time.Minute, } } const ( reusePolicyOff = "off" reusePolicySafe = "safe" reusePolicyAggressive = "aggressive" ) // WebViewPool manages a pool of pre-created WebViews for fast tab creation. type WebViewPool struct { wkCtx *WebKitContext settings *SettingsManager config PoolConfig pool chan *WebView poolMu sync.Mutex ctxMenuMu sync.RWMutex injector *ContentInjector router *MessageRouter filterApplier FilterApplier // Optional content filter applier ctxMenu *contextMenuPipeline // Optional context menu pipeline // Background color for WebViews (eliminates white flash) bg bgColor closed atomic.Bool wg sync.WaitGroup reusePolicy string terminatePolicy string acquireCount atomic.Uint64 acquirePoolHits atomic.Uint64 acquirePoolMisses atomic.Uint64 releaseCount atomic.Uint64 releaseReused atomic.Uint64 releaseDestroyed atomic.Uint64 releaseDropped atomic.Uint64 releaseTotalNanos atomic.Int64 acquireTotalNanos atomic.Int64 } // NewWebViewPool creates a new WebView pool. func NewWebViewPool( ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager, cfg PoolConfig, injector *ContentInjector, router *MessageRouter, ) *WebViewPool { if cfg.MaxSize <= 0 { cfg.MaxSize = 8 } if cfg.MinSize <= 0 { cfg.MinSize = 2 } if cfg.MinSize > cfg.MaxSize { cfg.MinSize = cfg.MaxSize } cfg.ReusePolicy = resolveReusePolicy(cfg.ReusePolicy) cfg.TerminatePolicy = resolveTerminatePolicy(cfg.TerminatePolicy) if ctx == nil { ctx = context.Background() } p := &WebViewPool{ wkCtx: wkCtx, settings: settings, config: cfg, pool: make(chan *WebView, cfg.MaxSize), injector: injector, router: router, reusePolicy: cfg.ReusePolicy, terminatePolicy: cfg.TerminatePolicy, } log := logging.FromContext(ctx) log.Debug(). Str("reuse_policy", p.reusePolicy). Str("terminate_policy", p.terminatePolicy). Msg("creating webview pool") return p } // SetFilterApplier sets the content filter applier. // Filters will be applied to all newly created WebViews. func (p *WebViewPool) SetFilterApplier(applier FilterApplier) { p.filterApplier = applier } // SetBackgroundColor sets the background color for newly created WebViews. // This color is shown before content is painted, eliminating white flash. func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) { p.bg.set(r, g, b, a) } // SetContextMenuPipeline sets the context menu pipeline for newly created WebViews. func (p *WebViewPool) SetContextMenuPipeline(pipeline *contextMenuPipeline) { p.ctxMenuMu.Lock() p.ctxMenu = pipeline p.ctxMenuMu.Unlock() } // Acquire gets a WebView from the pool or creates a new one. func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { log := logging.FromContext(ctx) start := time.Now() if p.closed.Load() { return nil, ErrPoolClosed } // Try to get from pool first (non-blocking) select { case wv := <-p.pool: if wv != nil && !wv.IsDestroyed() { if r, g, b, a := p.bg.get(); a > 0 { wv.SetBackgroundColor(float64(r), float64(g), float64(b), float64(a)) } wv.inner.AddCssClass("webview-themed") // Keep pooled WebViews hidden until we explicitly reveal them. wv.inner.SetVisible(false) // Ensure frontend is attached even if this WebView was pooled before injection was configured. _ = wv.AttachFrontend(ctx, p.injector, p.router) // Apply filters if available (may not have been applied during prewarm) if p.filterApplier != nil { p.filterApplier.ApplyTo(ctx, wv.ucm) } // Ensure context menu pipeline is set (may not have been available during prewarm) p.ctxMenuMu.RLock() ctxMenu := p.ctxMenu p.ctxMenuMu.RUnlock() if ctxMenu != nil { wv.contextMenu = ctxMenu if needsExplicitContextMenuSignalOnAcquire(wv, ctxMenu) { wv.connectContextMenuSignal(ctxMenu) } } wv.ReconnectSignals() p.acquireCount.Add(1) p.acquirePoolHits.Add(1) p.acquireTotalNanos.Add(time.Since(start).Nanoseconds()) log.Debug(). Uint64("id", uint64(wv.ID())). Dur("latency", time.Since(start)). Int("pool_size", len(p.pool)). Str("source", "pool"). Msg("acquired webview") return wv, nil } // Fall through to create new if the pooled one was destroyed default: // Pool empty } // Pool empty or pooled view was destroyed, create new log.Debug().Msg("pool empty, creating new webview") wv, err := p.createWebView(ctx) if err != nil { return nil, err } p.acquireCount.Add(1) p.acquirePoolMisses.Add(1) p.acquireTotalNanos.Add(time.Since(start).Nanoseconds()) // Re-check after creation: if pool closed during createWebView, destroy // the new WebView and return an error to avoid leaking resources. if p.closed.Load() { wv.DestroyWithPolicy(p.terminatePolicy) return nil, ErrPoolClosed } log.Debug(). Uint64("id", uint64(wv.ID())). Dur("latency", time.Since(start)). Int("pool_size", len(p.pool)). Str("source", "new"). Msg("acquired webview") return wv, nil } func needsExplicitContextMenuSignalOnAcquire(wv *WebView, pipeline *contextMenuPipeline) bool { return wv != nil && pipeline != nil && len(wv.signalIDs) > 0 } func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) { log := logging.FromContext(ctx) wv, err := NewWebView(ctx, p.wkCtx, p.settings, p.bg.toGdkRGBA()) if err != nil { return nil, err } // Add CSS class for theme background styling (prevents white flash) wv.inner.AddCssClass("webview-themed") // Keep WebView hidden until content is painted (see content.go:onLoadCommitted) wv.inner.SetVisible(false) if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil { log.Warn().Err(err).Msg("failed to attach frontend to new webview") } // Apply content filters if configured if p.filterApplier != nil { p.filterApplier.ApplyTo(ctx, wv.ucm) } // Wire context menu if configured p.ctxMenuMu.RLock() ctxMenu := p.ctxMenu p.ctxMenuMu.RUnlock() if ctxMenu != nil { wv.contextMenu = ctxMenu wv.connectContextMenuSignal(ctxMenu) } return wv, nil } // Release returns a WebView to the pool when configured and safe. // Default behavior remains conservative (`reuse_policy=off`) and destroys on release. func (p *WebViewPool) Release(ctx context.Context, wv *WebView) { log := logging.FromContext(ctx) start := time.Now() if wv == nil || wv.IsDestroyed() || p.closed.Load() { if wv != nil && !wv.IsDestroyed() { wv.DestroyWithPolicy(p.terminatePolicy) } p.releaseDropped.Add(1) return } action := "destroyed" if p.shouldReuseOnRelease(wv) { wv.ResetForPoolReuse() if p.tryPut(ctx, wv) { action = "reused" p.releaseReused.Add(1) } else { action = "destroyed_after_reuse_failed" wv.DestroyWithPolicy(p.terminatePolicy) p.releaseDestroyed.Add(1) } } else { wv.DestroyWithPolicy(p.terminatePolicy) p.releaseDestroyed.Add(1) } p.releaseCount.Add(1) elapsed := time.Since(start) p.releaseTotalNanos.Add(elapsed.Nanoseconds()) log.Debug(). Uint64("id", uint64(wv.ID())). Str("action", action). Str("reuse_policy", p.reusePolicy). Str("terminate_policy", p.terminatePolicy). Dur("latency", elapsed). Int("pool_size", len(p.pool)). Uint64("acquire_total", p.acquireCount.Load()). Uint64("acquire_hits", p.acquirePoolHits.Load()). Uint64("acquire_misses", p.acquirePoolMisses.Load()). Uint64("release_total", p.releaseCount.Load()). Uint64("release_reused", p.releaseReused.Load()). Uint64("release_destroyed", p.releaseDestroyed.Load()). Msg("released webview") } // PrewarmFirst creates exactly one WebView synchronously. // Call this during startup (before GTK activate) to ensure first Acquire() is instant. // This is the most impactful optimization for cold start since WebView creation // is the heaviest operation (spawns WebKit web process). // Returns error if WebView creation fails; caller should log and continue. func (p *WebViewPool) PrewarmFirst(ctx context.Context) error { if p.closed.Load() { return ErrPoolClosed } if len(p.pool) > 0 { return nil // Already have at least one } log := logging.FromContext(ctx) wv, err := p.createWebView(ctx) if err != nil { return err } if p.tryPut(ctx, wv) { log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start") return nil } wv.DestroyWithPolicy(p.terminatePolicy) // Pool somehow full (shouldn't happen) return nil } // Prewarm creates WebViews synchronously to populate the pool. // Must be called from the GTK main thread (after GTK application is initialized). func (p *WebViewPool) Prewarm(ctx context.Context, count int) { if count <= 0 { count = p.config.PrewarmCount } if count <= 0 { return } p.prewarmSync(ctx, count) } // PrewarmAsync schedules WebView creation on the GTK idle loop. // This avoids blocking startup (especially cold-start navigation) while still // warming up WebViews for subsequent tab creation. func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) { log := logging.FromContext(ctx) if count <= 0 { count = p.config.PrewarmCount } if count <= 0 { return } if p.closed.Load() { return } log.Debug().Int("count", count).Int("pool_size", len(p.pool)).Msg("scheduling async webview pool prewarm") p.wg.Add(1) var doneOnce sync.Once remaining := count var schedule func() schedule = func() { // Stop early if the caller canceled the operation. if err := ctx.Err(); err != nil { log.Debug().Err(err).Msg("async webview pool prewarm canceled") doneOnce.Do(p.wg.Done) return } cb := glib.SourceFunc(func(_ uintptr) bool { // Stop if the caller canceled the operation. if err := ctx.Err(); err != nil { log.Debug().Err(err).Msg("async webview pool prewarm canceled") doneOnce.Do(p.wg.Done) return false } // Stop if pool is closing/closed. if p.closed.Load() { doneOnce.Do(p.wg.Done) return false } // Stop early if we reached capacity. if len(p.pool) >= p.config.MaxSize { doneOnce.Do(p.wg.Done) return false } wv, err := p.createWebView(ctx) if err != nil { log.Warn().Err(err).Msg("failed to prewarm webview") } else { if p.tryPut(ctx, wv) { log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool") } else { wv.DestroyWithPolicy(p.terminatePolicy) } } remaining-- if remaining <= 0 { log.Debug().Int("pool_size", len(p.pool)).Msg("async webview pool prewarm complete") doneOnce.Do(p.wg.Done) return false } schedule() return false }) // Use LOW priority so initial navigation and other high-priority work runs first glib.IdleAddFull(glib.PRIORITY_LOW, &cb, 0, nil) } schedule() } // prewarmSync creates WebViews synchronously. func (p *WebViewPool) prewarmSync(ctx context.Context, count int) { log := logging.FromContext(ctx) for i := 0; i < count && !p.closed.Load(); i++ { // Check if pool is already at capacity if len(p.pool) >= p.config.MaxSize { break } wv, err := p.createWebView(ctx) if err != nil { log.Warn().Err(err).Msg("failed to prewarm webview") continue } // Try to add to pool if p.tryPut(ctx, wv) { log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool") } else { // Pool full, destroy wv.DestroyWithPolicy(p.terminatePolicy) } // Small delay between creations to avoid overwhelming the system time.Sleep(50 * time.Millisecond) } } // Size returns the current number of WebViews in the pool. func (p *WebViewPool) Size() int { return len(p.pool) } // RefreshScripts clears and re-injects scripts to all pooled WebViews. // This is needed after adw.Init() to update dark mode preference that was // injected with the wrong value during early prewarming. func (p *WebViewPool) RefreshScripts(ctx context.Context) { if p.closed.Load() || p.injector == nil { return } log := logging.FromContext(ctx) // Drain and refresh each WebView, then put it back count := len(p.pool) if count == 0 { return } refreshed := 0 refreshLoop: for i := 0; i < count; i++ { select { case wv := <-p.pool: if wv == nil || wv.IsDestroyed() { continue } // Clear existing scripts and re-inject with updated values if wv.ucm != nil { wv.ucm.RemoveAllScripts() wv.ucm.RemoveAllStyleSheets() p.injector.InjectScripts(ctx, wv.ucm, wv.ID()) } // Put back in pool if p.tryPut(ctx, wv) { refreshed++ } else { wv.DestroyWithPolicy(p.terminatePolicy) } default: // Pool was modified during iteration, stop break refreshLoop } } log.Debug(). Int("refreshed", refreshed). Bool("prefers_dark", p.injector.PrefersDark()). Msg("refreshed scripts in pooled webviews") } // Close shuts down the pool and destroys all pooled WebViews. func (p *WebViewPool) Close(ctx context.Context) { if p.closed.Swap(true) { return // Already closed } log := logging.FromContext(ctx) // Wait for any background operations p.wg.Wait() // Drain and destroy all pooled views p.poolMu.Lock() close(p.pool) p.poolMu.Unlock() for wv := range p.pool { if wv != nil && !wv.IsDestroyed() { wv.Destroy() } } log.Debug().Msg("webview pool closed") } func (p *WebViewPool) shouldReuseOnRelease(wv *WebView) bool { if p.reusePolicy == reusePolicyOff || wv == nil || wv.IsDestroyed() || wv.IsRelated() { return false } if len(p.pool) >= p.config.MaxSize { return false } switch p.reusePolicy { case reusePolicySafe: // Safe mode: only reuse views that haven't navigated, avoiding // back/forward/page context leaking across tabs. return !wv.HasNavigationActivity() case reusePolicyAggressive: // Aggressive mode: reuse regardless of navigation activity. // The view is reset via ResetForPoolReuse before being returned to the pool. return true default: return false } } func (p *WebViewPool) tryPut(ctx context.Context, wv *WebView) bool { log := logging.FromContext(ctx) p.poolMu.Lock() defer p.poolMu.Unlock() if p.closed.Load() { return false } select { case p.pool <- wv: log.Debug().Uint64("id", uint64(wv.ID())).Int("pool_size", len(p.pool)).Msg("returned webview to pool") return true default: return false } } func resolveReusePolicy(raw string) string { switch strings.ToLower(strings.TrimSpace(defaultIfEmpty(raw, os.Getenv("DUMBER_WEBVIEW_REUSE_POLICY")))) { case reusePolicySafe: return reusePolicySafe case reusePolicyAggressive: return reusePolicyAggressive case reusePolicyOff, "": return reusePolicyOff default: return reusePolicyOff } } func defaultIfEmpty(value, fallback string) string { if strings.TrimSpace(value) != "" { return value } return fallback }