1package webkit
2
3import (
4 "context"
5 "sync"
6 "sync/atomic"
7 "time"
8
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/puregotk-webkit/webkit"
11)
12
13// FilterApplier applies content filters to a UserContentManager.
14// This interface decouples the pool from the filtering package.
15type FilterApplier interface {
16 ApplyTo(ctx context.Context, ucm *webkit.UserContentManager)
17}
18
19// PoolConfig configures the WebView pool behavior.
20type PoolConfig struct {
21 // MinSize is the minimum number of warm WebViews to maintain.
22 MinSize int
23 // MaxSize is the maximum number of WebViews to keep in the pool.
24 MaxSize int
25 // PrewarmCount is the number of WebViews to pre-create on startup.
26 PrewarmCount int
27 // IdleTimeout is how long idle views stay in the pool before being destroyed.
28 IdleTimeout time.Duration
29}
30
31// DefaultPoolConfig returns sensible defaults for the pool.
32func DefaultPoolConfig() PoolConfig {
33 return PoolConfig{
34 MinSize: 2,
35 MaxSize: 8,
36 PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation
37 IdleTimeout: 5 * time.Minute,
38 }
39}
40
41// WebViewPool manages a pool of pre-created WebViews for fast tab creation.
42type WebViewPool struct {
43 wkCtx *WebKitContext
44 settings *SettingsManager
45 config PoolConfig
46
47 pool chan *WebView
48 injector *ContentInjector
49 router *MessageRouter
50 filterApplier FilterApplier // Optional content filter applier
51
52 // Background color for WebViews (eliminates white flash)
53 bgR, bgG, bgB, bgA float32
54 bgMu sync.RWMutex
55
56 closed atomic.Bool
57 wg sync.WaitGroup
58}
59
60// NewWebViewPool creates a new WebView pool.
61func NewWebViewPool(
62 ctx context.Context,
63 wkCtx *WebKitContext,
64 settings *SettingsManager,
65 cfg PoolConfig,
66 injector *ContentInjector,
67 router *MessageRouter,
68) *WebViewPool {
69 if cfg.MaxSize <= 0 {
70 cfg.MaxSize = 8
71 }
72 if cfg.MinSize <= 0 {
73 cfg.MinSize = 2
74 }
75 if cfg.MinSize > cfg.MaxSize {
76 cfg.MinSize = cfg.MaxSize
77 }
78 if ctx == nil {
79 ctx = context.Background()
80 }
81
82 p := &WebViewPool{
83 wkCtx: wkCtx,
84 settings: settings,
85 config: cfg,
86 pool: make(chan *WebView, cfg.MaxSize),
87 injector: injector,
88 router: router,
89 }
90
91 log := logging.FromContext(ctx)
92 log.Debug().Msg("creating webview pool")
93
94 return p
95}
96
97// SetFilterApplier sets the content filter applier.
98// Filters will be applied to all newly created WebViews.
99func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
100 p.filterApplier = applier
101}
102
103// SetBackgroundColor sets the background color for newly created WebViews.
104// This color is shown before content is painted, eliminating white flash.
105func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
106 p.bgMu.Lock()
107 p.bgR, p.bgG, p.bgB, p.bgA = r, g, b, a
108 p.bgMu.Unlock()
109}
110
111// Acquire gets a WebView from the pool or creates a new one.
112func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
113 log := logging.FromContext(ctx)
114
115 if p.closed.Load() {
116 return nil, context.Canceled
117 }
118
119 // Try to get from pool first (non-blocking)
120 select {
121 case wv := <-p.pool:
122 if wv != nil && !wv.IsDestroyed() {
123 // Ensure frontend is attached even if this WebView was pooled before injection was configured.
124 _ = wv.AttachFrontend(ctx, p.injector, p.router)
125 // Apply filters if available (may not have been applied during prewarm)
126 if p.filterApplier != nil {
127 p.filterApplier.ApplyTo(ctx, wv.ucm)
128 }
129 log.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool")
130 return wv, nil
131 }
132 // Fall through to create new if the pooled one was destroyed
133 default:
134 // Pool empty
135 }
136
137 // Pool empty or pooled view was destroyed, create new
138 log.Debug().Msg("pool empty, creating new webview")
139 return p.createWebView(ctx)
140}
141
142func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
143 log := logging.FromContext(ctx)
144
145 wv, err := NewWebView(ctx, p.wkCtx, p.settings)
146 if err != nil {
147 return nil, err
148 }
149
150 // Set background color to match theme (eliminates white flash)
151 p.bgMu.RLock()
152 r, g, b, a := p.bgR, p.bgG, p.bgB, p.bgA
153 p.bgMu.RUnlock()
154 if a > 0 { // Only set if a valid color was configured
155 wv.SetBackgroundColor(r, g, b, a)
156 }
157
158 // Keep WebView hidden until content is painted (see content.go:onLoadCommitted)
159 wv.inner.SetVisible(false)
160
161 if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {
162 log.Warn().Err(err).Msg("failed to attach frontend to new webview")
163 }
164
165 // Apply content filters if configured
166 if p.filterApplier != nil {
167 p.filterApplier.ApplyTo(ctx, wv.ucm)
168 }
169
170 return wv, nil
171}
172
173// Release returns a WebView to the pool.
174// For safety, we always destroy and never pool WebViews that have been used.
175// This prevents crashes from GLib signals firing after the widget tree is modified.
176// The pool is only used for prewarmed WebViews that haven't been attached to UI yet.
177func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
178 log := logging.FromContext(ctx)
179
180 if wv == nil || wv.IsDestroyed() || p.closed.Load() {
181 if wv != nil && !wv.IsDestroyed() {
182 wv.Destroy()
183 }
184 return
185 }
186
187 // CRITICAL: Always destroy used WebViews instead of pooling them.
188 // GLib signals are connected with closures that can fire asynchronously
189 // even after we clear Go callbacks. Loading about:blank triggers signals
190 // that can crash if the widget tree has been modified.
191 //
192 // The pool is designed for prewarmed WebViews only, not for recycling
193 // WebViews that have been attached to the UI.
194 log.Debug().Uint64("id", uint64(wv.ID())).Msg("destroying webview (not pooling used views)")
195 wv.Destroy()
196}
197
198// Prewarm creates WebViews synchronously to populate the pool.
199// Must be called from the GTK main thread (after GTK application is initialized).
200func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
201 if count <= 0 {
202 count = p.config.PrewarmCount
203 }
204 if count <= 0 {
205 return
206 }
207 p.prewarmSync(ctx, count)
208}
209
210// prewarmSync creates WebViews synchronously.
211func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
212 log := logging.FromContext(ctx)
213
214 for i := 0; i < count && !p.closed.Load(); i++ {
215 // Check if pool is already at capacity
216 if len(p.pool) >= p.config.MaxSize {
217 break
218 }
219
220 wv, err := p.createWebView(ctx)
221 if err != nil {
222 log.Warn().Err(err).Msg("failed to prewarm webview")
223 continue
224 }
225
226 // Try to add to pool
227 select {
228 case p.pool <- wv:
229 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
230 default:
231 // Pool full, destroy
232 wv.Destroy()
233 }
234
235 // Small delay between creations to avoid overwhelming the system
236 time.Sleep(50 * time.Millisecond)
237 }
238}
239
240// Size returns the current number of WebViews in the pool.
241func (p *WebViewPool) Size() int {
242 return len(p.pool)
243}
244
245// Close shuts down the pool and destroys all pooled WebViews.
246func (p *WebViewPool) Close(ctx context.Context) {
247 if p.closed.Swap(true) {
248 return // Already closed
249 }
250
251 log := logging.FromContext(ctx)
252
253 // Wait for any background operations
254 p.wg.Wait()
255
256 // Drain and destroy all pooled views
257 close(p.pool)
258 for wv := range p.pool {
259 if wv != nil && !wv.IsDestroyed() {
260 wv.Destroy()
261 }
262 }
263
264 log.Debug().Msg("webview pool closed")
265}