1package webkit
2
3import (
4 "context"
5 "errors"
6 "sync"
7 "sync/atomic"
8 "time"
9
10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/puregotk-webkit/webkit"
12 "github.com/jwijenbergh/puregotk/v4/glib"
13)
14
15// ErrPoolClosed is returned when operations are attempted on a closed pool.
16var ErrPoolClosed = errors.New("webview pool is closed")
17
18// FilterApplier applies content filters to a UserContentManager.
19// This interface decouples the pool from the filtering package.
20type FilterApplier interface {
21 ApplyTo(ctx context.Context, ucm *webkit.UserContentManager)
22}
23
24// PoolConfig configures the WebView pool behavior.
25type PoolConfig struct {
26 // MinSize is the minimum number of warm WebViews to maintain.
27 MinSize int
28 // MaxSize is the maximum number of WebViews to keep in the pool.
29 MaxSize int
30 // PrewarmCount is the number of WebViews to pre-create on startup.
31 PrewarmCount int
32 // IdleTimeout is how long idle views stay in the pool before being destroyed.
33 IdleTimeout time.Duration
34}
35
36// DefaultPoolConfig returns sensible defaults for the pool.
37func DefaultPoolConfig() PoolConfig {
38 return PoolConfig{
39 MinSize: 2,
40 MaxSize: 8,
41 PrewarmCount: 4, // Pre-create 4 WebViews for faster initial tab creation
42 IdleTimeout: 5 * time.Minute,
43 }
44}
45
46// WebViewPool manages a pool of pre-created WebViews for fast tab creation.
47type WebViewPool struct {
48 wkCtx *WebKitContext
49 settings *SettingsManager
50 config PoolConfig
51
52 pool chan *WebView
53 injector *ContentInjector
54 router *MessageRouter
55 filterApplier FilterApplier // Optional content filter applier
56
57 // Background color for WebViews (eliminates white flash)
58 bg bgColor
59
60 closed atomic.Bool
61 wg sync.WaitGroup
62}
63
64// NewWebViewPool creates a new WebView pool.
65func NewWebViewPool(
66 ctx context.Context,
67 wkCtx *WebKitContext,
68 settings *SettingsManager,
69 cfg PoolConfig,
70 injector *ContentInjector,
71 router *MessageRouter,
72) *WebViewPool {
73 if cfg.MaxSize <= 0 {
74 cfg.MaxSize = 8
75 }
76 if cfg.MinSize <= 0 {
77 cfg.MinSize = 2
78 }
79 if cfg.MinSize > cfg.MaxSize {
80 cfg.MinSize = cfg.MaxSize
81 }
82 if ctx == nil {
83 ctx = context.Background()
84 }
85
86 p := &WebViewPool{
87 wkCtx: wkCtx,
88 settings: settings,
89 config: cfg,
90 pool: make(chan *WebView, cfg.MaxSize),
91 injector: injector,
92 router: router,
93 }
94
95 log := logging.FromContext(ctx)
96 log.Debug().Msg("creating webview pool")
97
98 return p
99}
100
101// SetFilterApplier sets the content filter applier.
102// Filters will be applied to all newly created WebViews.
103func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
104 p.filterApplier = applier
105}
106
107// SetBackgroundColor sets the background color for newly created WebViews.
108// This color is shown before content is painted, eliminating white flash.
109func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
110 p.bg.set(r, g, b, a)
111}
112
113// Acquire gets a WebView from the pool or creates a new one.
114func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
115 log := logging.FromContext(ctx)
116
117 if p.closed.Load() {
118 return nil, ErrPoolClosed
119 }
120
121 // Try to get from pool first (non-blocking)
122 select {
123 case wv := <-p.pool:
124 if wv != nil && !wv.IsDestroyed() {
125 if r, g, b, a := p.bg.get(); a > 0 {
126 wv.SetBackgroundColor(r, g, b, a)
127 }
128 wv.inner.AddCssClass("webview-themed")
129 // Keep pooled WebViews hidden until we explicitly reveal them.
130 wv.inner.SetVisible(false)
131
132 // Ensure frontend is attached even if this WebView was pooled before injection was configured.
133 _ = wv.AttachFrontend(ctx, p.injector, p.router)
134 // Apply filters if available (may not have been applied during prewarm)
135 if p.filterApplier != nil {
136 p.filterApplier.ApplyTo(ctx, wv.ucm)
137 }
138 log.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool")
139 return wv, nil
140 }
141 // Fall through to create new if the pooled one was destroyed
142 default:
143 // Pool empty
144 }
145
146 // Pool empty or pooled view was destroyed, create new
147 log.Debug().Msg("pool empty, creating new webview")
148 return p.createWebView(ctx)
149}
150
151func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
152 log := logging.FromContext(ctx)
153
154 wv, err := NewWebView(ctx, p.wkCtx, p.settings, p.bg.toGdkRGBA())
155 if err != nil {
156 return nil, err
157 }
158
159 // Add CSS class for theme background styling (prevents white flash)
160 wv.inner.AddCssClass("webview-themed")
161
162 // Keep WebView hidden until content is painted (see content.go:onLoadCommitted)
163 wv.inner.SetVisible(false)
164
165 if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {
166 log.Warn().Err(err).Msg("failed to attach frontend to new webview")
167 }
168
169 // Apply content filters if configured
170 if p.filterApplier != nil {
171 p.filterApplier.ApplyTo(ctx, wv.ucm)
172 }
173
174 return wv, nil
175}
176
177// Release returns a WebView to the pool.
178// For safety, we always destroy and never pool WebViews that have been used.
179// This prevents crashes from GLib signals firing after the widget tree is modified.
180// The pool is only used for prewarmed WebViews that haven't been attached to UI yet.
181func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
182 log := logging.FromContext(ctx)
183
184 if wv == nil || wv.IsDestroyed() || p.closed.Load() {
185 if wv != nil && !wv.IsDestroyed() {
186 wv.Destroy()
187 }
188 return
189 }
190
191 // CRITICAL: Always destroy used WebViews instead of pooling them.
192 // GLib signals are connected with closures that can fire asynchronously
193 // even after we clear Go callbacks. Loading about:blank triggers signals
194 // that can crash if the widget tree has been modified.
195 //
196 // The pool is designed for prewarmed WebViews only, not for recycling
197 // WebViews that have been attached to the UI.
198 log.Debug().Uint64("id", uint64(wv.ID())).Msg("destroying webview (not pooling used views)")
199 wv.Destroy()
200}
201
202// PrewarmFirst creates exactly one WebView synchronously.
203// Call this during startup (before GTK activate) to ensure first Acquire() is instant.
204// This is the most impactful optimization for cold start since WebView creation
205// is the heaviest operation (spawns WebKit web process).
206// Returns error if WebView creation fails; caller should log and continue.
207func (p *WebViewPool) PrewarmFirst(ctx context.Context) error {
208 if p.closed.Load() {
209 return ErrPoolClosed
210 }
211 if len(p.pool) > 0 {
212 return nil // Already have at least one
213 }
214
215 log := logging.FromContext(ctx)
216 wv, err := p.createWebView(ctx)
217 if err != nil {
218 return err
219 }
220
221 select {
222 case p.pool <- wv:
223 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed first webview for cold start")
224 default:
225 wv.Destroy() // Pool somehow full (shouldn't happen)
226 }
227 return nil
228}
229
230// Prewarm creates WebViews synchronously to populate the pool.
231// Must be called from the GTK main thread (after GTK application is initialized).
232func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
233 if count <= 0 {
234 count = p.config.PrewarmCount
235 }
236 if count <= 0 {
237 return
238 }
239 p.prewarmSync(ctx, count)
240}
241
242// PrewarmAsync schedules WebView creation on the GTK idle loop.
243// This avoids blocking startup (especially cold-start navigation) while still
244// warming up WebViews for subsequent tab creation.
245func (p *WebViewPool) PrewarmAsync(ctx context.Context, count int) {
246 log := logging.FromContext(ctx)
247
248 if count <= 0 {
249 count = p.config.PrewarmCount
250 }
251 if count <= 0 {
252 return
253 }
254 if p.closed.Load() {
255 return
256 }
257
258 log.Debug().Int("count", count).Int("pool_size", len(p.pool)).Msg("scheduling async webview pool prewarm")
259
260 p.wg.Add(1)
261 var doneOnce sync.Once
262 remaining := count
263
264 var schedule func()
265 schedule = func() {
266 // Stop early if the caller canceled the operation.
267 if err := ctx.Err(); err != nil {
268 log.Debug().Err(err).Msg("async webview pool prewarm canceled")
269 doneOnce.Do(p.wg.Done)
270 return
271 }
272
273 cb := glib.SourceFunc(func(_ uintptr) bool {
274 // Stop if the caller canceled the operation.
275 if err := ctx.Err(); err != nil {
276 log.Debug().Err(err).Msg("async webview pool prewarm canceled")
277 doneOnce.Do(p.wg.Done)
278 return false
279 }
280
281 // Stop if pool is closing/closed.
282 if p.closed.Load() {
283 doneOnce.Do(p.wg.Done)
284 return false
285 }
286
287 // Stop early if we reached capacity.
288 if len(p.pool) >= p.config.MaxSize {
289 doneOnce.Do(p.wg.Done)
290 return false
291 }
292
293 wv, err := p.createWebView(ctx)
294 if err != nil {
295 log.Warn().Err(err).Msg("failed to prewarm webview")
296 } else {
297 select {
298 case p.pool <- wv:
299 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
300 default:
301 wv.Destroy()
302 }
303 }
304
305 remaining--
306 if remaining <= 0 {
307 log.Debug().Int("pool_size", len(p.pool)).Msg("async webview pool prewarm complete")
308 doneOnce.Do(p.wg.Done)
309 return false
310 }
311
312 schedule()
313 return false
314 })
315 glib.IdleAdd(&cb, 0)
316 }
317
318 schedule()
319}
320
321// prewarmSync creates WebViews synchronously.
322func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
323 log := logging.FromContext(ctx)
324
325 for i := 0; i < count && !p.closed.Load(); i++ {
326 // Check if pool is already at capacity
327 if len(p.pool) >= p.config.MaxSize {
328 break
329 }
330
331 wv, err := p.createWebView(ctx)
332 if err != nil {
333 log.Warn().Err(err).Msg("failed to prewarm webview")
334 continue
335 }
336
337 // Try to add to pool
338 select {
339 case p.pool <- wv:
340 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
341 default:
342 // Pool full, destroy
343 wv.Destroy()
344 }
345
346 // Small delay between creations to avoid overwhelming the system
347 time.Sleep(50 * time.Millisecond)
348 }
349}
350
351// Size returns the current number of WebViews in the pool.
352func (p *WebViewPool) Size() int {
353 return len(p.pool)
354}
355
356// RefreshScripts clears and re-injects scripts to all pooled WebViews.
357// This is needed after adw.Init() to update dark mode preference that was
358// injected with the wrong value during early prewarming.
359func (p *WebViewPool) RefreshScripts(ctx context.Context) {
360 if p.closed.Load() || p.injector == nil {
361 return
362 }
363
364 log := logging.FromContext(ctx)
365
366 // Drain and refresh each WebView, then put it back
367 count := len(p.pool)
368 if count == 0 {
369 return
370 }
371
372 refreshed := 0
373refreshLoop:
374 for i := 0; i < count; i++ {
375 select {
376 case wv := <-p.pool:
377 if wv == nil || wv.IsDestroyed() {
378 continue
379 }
380
381 // Clear existing scripts and re-inject with updated values
382 if wv.ucm != nil {
383 wv.ucm.RemoveAllScripts()
384 wv.ucm.RemoveAllStyleSheets()
385 p.injector.InjectScripts(ctx, wv.ucm, wv.ID())
386 }
387
388 // Put back in pool
389 select {
390 case p.pool <- wv:
391 refreshed++
392 default:
393 wv.Destroy()
394 }
395 default:
396 // Pool was modified during iteration, stop
397 break refreshLoop
398 }
399 }
400
401 log.Debug().
402 Int("refreshed", refreshed).
403 Bool("prefers_dark", p.injector.PrefersDark()).
404 Msg("refreshed scripts in pooled webviews")
405}
406
407// Close shuts down the pool and destroys all pooled WebViews.
408func (p *WebViewPool) Close(ctx context.Context) {
409 if p.closed.Swap(true) {
410 return // Already closed
411 }
412
413 log := logging.FromContext(ctx)
414
415 // Wait for any background operations
416 p.wg.Wait()
417
418 // Drain and destroy all pooled views
419 close(p.pool)
420 for wv := range p.pool {
421 if wv != nil && !wv.IsDestroyed() {
422 wv.Destroy()
423 }
424 }
425
426 log.Debug().Msg("webview pool closed")
427}