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: 2,
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 closed atomic.Bool
53 wg sync.WaitGroup
54}
55
56// NewWebViewPool creates a new WebView pool.
57func NewWebViewPool(
58 ctx context.Context,
59 wkCtx *WebKitContext,
60 settings *SettingsManager,
61 cfg PoolConfig,
62 injector *ContentInjector,
63 router *MessageRouter,
64) *WebViewPool {
65 if cfg.MaxSize <= 0 {
66 cfg.MaxSize = 8
67 }
68 if cfg.MinSize <= 0 {
69 cfg.MinSize = 2
70 }
71 if cfg.MinSize > cfg.MaxSize {
72 cfg.MinSize = cfg.MaxSize
73 }
74 if ctx == nil {
75 ctx = context.Background()
76 }
77
78 p := &WebViewPool{
79 wkCtx: wkCtx,
80 settings: settings,
81 config: cfg,
82 pool: make(chan *WebView, cfg.MaxSize),
83 injector: injector,
84 router: router,
85 }
86
87 log := logging.FromContext(ctx)
88 log.Debug().Msg("creating webview pool")
89
90 return p
91}
92
93// SetFilterApplier sets the content filter applier.
94// Filters will be applied to all newly created WebViews.
95func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
96 p.filterApplier = applier
97}
98
99// Acquire gets a WebView from the pool or creates a new one.
100func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
101 log := logging.FromContext(ctx)
102
103 if p.closed.Load() {
104 return nil, context.Canceled
105 }
106
107 // Try to get from pool first (non-blocking)
108 select {
109 case wv := <-p.pool:
110 if wv != nil && !wv.IsDestroyed() {
111 // Ensure frontend is attached even if this WebView was pooled before injection was configured.
112 _ = wv.AttachFrontend(ctx, p.injector, p.router)
113 // Apply filters if available (may not have been applied during prewarm)
114 if p.filterApplier != nil {
115 p.filterApplier.ApplyTo(ctx, wv.ucm)
116 }
117 log.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool")
118 return wv, nil
119 }
120 // Fall through to create new if the pooled one was destroyed
121 default:
122 // Pool empty
123 }
124
125 // Pool empty or pooled view was destroyed, create new
126 log.Debug().Msg("pool empty, creating new webview")
127 return p.createWebView(ctx)
128}
129
130func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
131 log := logging.FromContext(ctx)
132
133 wv, err := NewWebView(ctx, p.wkCtx, p.settings)
134 if err != nil {
135 return nil, err
136 }
137
138 // Ensure WebView is visible when used
139 wv.inner.SetVisible(true)
140
141 if err := wv.AttachFrontend(ctx, p.injector, p.router); err != nil {
142 log.Warn().Err(err).Msg("failed to attach frontend to new webview")
143 }
144
145 // Apply content filters if configured
146 if p.filterApplier != nil {
147 p.filterApplier.ApplyTo(ctx, wv.ucm)
148 }
149
150 return wv, nil
151}
152
153// Release returns a WebView to the pool.
154// For safety, we always destroy and never pool WebViews that have been used.
155// This prevents crashes from GLib signals firing after the widget tree is modified.
156// The pool is only used for prewarmed WebViews that haven't been attached to UI yet.
157func (p *WebViewPool) Release(ctx context.Context, wv *WebView) {
158 log := logging.FromContext(ctx)
159
160 if wv == nil || wv.IsDestroyed() || p.closed.Load() {
161 if wv != nil && !wv.IsDestroyed() {
162 wv.Destroy()
163 }
164 return
165 }
166
167 // CRITICAL: Always destroy used WebViews instead of pooling them.
168 // GLib signals are connected with closures that can fire asynchronously
169 // even after we clear Go callbacks. Loading about:blank triggers signals
170 // that can crash if the widget tree has been modified.
171 //
172 // The pool is designed for prewarmed WebViews only, not for recycling
173 // WebViews that have been attached to the UI.
174 log.Debug().Uint64("id", uint64(wv.ID())).Msg("destroying webview (not pooling used views)")
175 wv.Destroy()
176}
177
178// Prewarm creates WebViews synchronously to populate the pool.
179// Must be called from the GTK main thread (after GTK application is initialized).
180func (p *WebViewPool) Prewarm(ctx context.Context, count int) {
181 if count <= 0 {
182 count = p.config.PrewarmCount
183 }
184 if count <= 0 {
185 return
186 }
187 p.prewarmSync(ctx, count)
188}
189
190// prewarmSync creates WebViews synchronously.
191func (p *WebViewPool) prewarmSync(ctx context.Context, count int) {
192 log := logging.FromContext(ctx)
193
194 for i := 0; i < count && !p.closed.Load(); i++ {
195 // Check if pool is already at capacity
196 if len(p.pool) >= p.config.MaxSize {
197 break
198 }
199
200 wv, err := p.createWebView(ctx)
201 if err != nil {
202 log.Warn().Err(err).Msg("failed to prewarm webview")
203 continue
204 }
205
206 // Try to add to pool
207 select {
208 case p.pool <- wv:
209 log.Debug().Uint64("id", uint64(wv.ID())).Msg("prewarmed webview added to pool")
210 default:
211 // Pool full, destroy
212 wv.Destroy()
213 }
214
215 // Small delay between creations to avoid overwhelming the system
216 time.Sleep(50 * time.Millisecond)
217 }
218}
219
220// Size returns the current number of WebViews in the pool.
221func (p *WebViewPool) Size() int {
222 return len(p.pool)
223}
224
225// Close shuts down the pool and destroys all pooled WebViews.
226func (p *WebViewPool) Close(ctx context.Context) {
227 if p.closed.Swap(true) {
228 return // Already closed
229 }
230
231 log := logging.FromContext(ctx)
232
233 // Wait for any background operations
234 p.wg.Wait()
235
236 // Drain and destroy all pooled views
237 close(p.pool)
238 for wv := range p.pool {
239 if wv != nil && !wv.IsDestroyed() {
240 wv.Destroy()
241 }
242 }
243
244 log.Debug().Msg("webview pool closed")
245}