1// Package adapter provides UI-layer adapters that bridge domain services to GTK.
2package adapter
3
4import (
5 "context"
6 "sync"
7
8 domainurl "github.com/bnema/dumber/internal/domain/url"
9 "github.com/bnema/dumber/internal/infrastructure/favicon"
10 "github.com/bnema/dumber/internal/infrastructure/webkit"
11 "github.com/bnema/dumber/internal/logging"
12 "github.com/jwijenbergh/puregotk/v4/gdk"
13 "github.com/jwijenbergh/puregotk/v4/gio"
14 "github.com/jwijenbergh/puregotk/v4/glib"
15)
16
17// FaviconAdapter bridges the domain FaviconService to GTK by providing
18// gdk.Texture conversion and WebKit FaviconDatabase integration.
19type FaviconAdapter struct {
20 service *favicon.Service
21 faviconDB *webkit.FaviconDatabase
22 textureCache map[string]*gdk.Texture
23 mu sync.RWMutex
24}
25
26// NewFaviconAdapter creates a new FaviconAdapter.
27// The faviconDB can be nil if WebKit favicon database is not available.
28func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDatabase) *FaviconAdapter {
29 return &FaviconAdapter{
30 service: service,
31 faviconDB: faviconDB,
32 textureCache: make(map[string]*gdk.Texture),
33 }
34}
35
36// GetTexture returns a cached texture for the domain, or nil if not cached.
37func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
38 if domain == "" {
39 return nil
40 }
41
42 a.mu.RLock()
43 texture := a.textureCache[domain]
44 a.mu.RUnlock()
45 return texture
46}
47
48// GetTextureByURL returns a cached texture by extracting domain from URL.
49func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
50 domain := domainurl.ExtractDomain(pageURL)
51 return a.GetTexture(domain)
52}
53
54// GetOrFetch retrieves a favicon texture, checking caches and fetching if needed.
55// The callback is invoked on the GTK main thread with the texture (or nil).
56func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callback func(*gdk.Texture)) {
57 if callback == nil {
58 return
59 }
60
61 log := logging.FromContext(ctx)
62 domain := domainurl.ExtractDomain(pageURL)
63
64 // Check texture cache first
65 if texture := a.GetTexture(domain); texture != nil {
66 // Ensure sized PNG exists for CLI tools (async, idempotent)
67 go a.ensureSizedPNG(ctx, domain)
68 callback(texture)
69 return
70 }
71
72 // Check service cache (memory + disk) and convert to texture
73 if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
74 texture := bytesToTexture(data)
75 if texture != nil {
76 a.setTexture(domain, texture)
77 // Ensure sized PNG exists for CLI tools (async, idempotent)
78 go a.ensureSizedPNG(ctx, domain)
79 callback(texture)
80 return
81 }
82 }
83
84 // Helper to invoke callback on main thread
85 invokeCallback := func(texture *gdk.Texture) {
86 cb := glib.SourceFunc(func(uintptr) bool {
87 callback(texture)
88 return false
89 })
90 glib.IdleAdd(&cb, 0)
91 }
92
93 // Helper to fetch via service and invoke callback
94 fetchViaService := func() {
95 go func() {
96 data, err := a.service.Get(ctx, domain)
97 if err != nil || len(data) == 0 {
98 invokeCallback(nil)
99 return
100 }
101
102 texture := bytesToTexture(data)
103 if texture != nil {
104 a.setTexture(domain, texture)
105 }
106 invokeCallback(texture)
107 }()
108 }
109
110 // Try WebKit FaviconDatabase if available
111 if a.faviconDB != nil {
112 asyncCb := gio.AsyncReadyCallback(func(_ uintptr, resultPtr uintptr, _ uintptr) {
113 if resultPtr == 0 {
114 log.Debug().Str("domain", domain).Msg("favicon not in webkit db, fetching via service")
115 fetchViaService()
116 return
117 }
118
119 result := &gio.AsyncResultBase{Ptr: resultPtr}
120 texture, err := a.faviconDB.GetFaviconFinish(result)
121 if err != nil || texture == nil {
122 log.Debug().Str("domain", domain).Msg("favicon not in webkit db, fetching via service")
123 fetchViaService()
124 return
125 }
126
127 // Found in WebKit database
128 a.setTexture(domain, texture)
129 invokeCallback(texture)
130
131 // Ensure disk cache is populated (async)
132 go a.service.EnsureDiskCache(ctx, domain)
133 })
134
135 a.faviconDB.GetFavicon(pageURL, nil, &asyncCb, 0)
136 return
137 }
138
139 // No WebKit DB, fetch via service directly
140 fetchViaService()
141}
142
143// StoreFromWebKit stores a favicon texture received from WebKit signals.
144// Also ensures the favicon is persisted to disk cache via the service.
145func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) {
146 if texture == nil {
147 return
148 }
149
150 domain := domainurl.ExtractDomain(pageURL)
151 if domain == "" {
152 return
153 }
154
155 log := logging.FromContext(ctx)
156
157 // Store in texture cache
158 a.setTexture(domain, texture)
159
160 // Export as PNG for CLI tools (rofi/fuzzel) - do this on main thread
161 // since GTK texture operations need to happen there
162 pngPath := a.service.DiskPathPNG(domain)
163 if pngPath != "" && !a.service.HasPNGOnDisk(domain) {
164 if ok := texture.SaveToPng(pngPath); !ok {
165 log.Warn().Str("domain", domain).Str("path", pngPath).Msg("failed to save favicon PNG")
166 } else {
167 log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
168
169 // Create normalized sized copy for dmenu/fuzzel (async)
170 // The original PNG is now on disk, so this should succeed
171 go func() {
172 if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
173 log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG after save")
174 } else {
175 log.Debug().Str("domain", domain).Msg("created sized PNG after save")
176 }
177 }()
178 }
179 }
180
181 // Ensure disk cache is populated (async, in background)
182 go a.service.EnsureDiskCache(ctx, domain)
183}
184
185// StoreFromWebKitWithOrigin stores a favicon for both current URL and original URL.
186// Used when redirects occur (e.g., example.com → www.example.com).
187func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
188 ctx context.Context, currentURL, originURL string, texture *gdk.Texture,
189) {
190 a.StoreFromWebKit(ctx, currentURL, texture)
191
192 // Also store under original URL domain if different
193 if originURL != "" && originURL != currentURL {
194 log := logging.FromContext(ctx)
195 originDomain := domainurl.ExtractDomain(originURL)
196 currentDomain := domainurl.ExtractDomain(currentURL)
197 if originDomain != "" && originDomain != currentDomain {
198 a.setTexture(originDomain, texture)
199
200 // Export PNG for origin domain too
201 pngPath := a.service.DiskPathPNG(originDomain)
202 if pngPath != "" && !a.service.HasPNGOnDisk(originDomain) {
203 if ok := texture.SaveToPng(pngPath); !ok {
204 log.Warn().Str("domain", originDomain).Str("path", pngPath).Msg("failed to save origin favicon PNG")
205 } else {
206 log.Debug().Str("domain", originDomain).Str("path", pngPath).Msg("saved origin favicon PNG")
207
208 // Create normalized sized copy for dmenu/fuzzel (async)
209 go func() {
210 if err := a.service.EnsureSizedPNG(ctx, originDomain, favicon.NormalizedIconSize); err != nil {
211 log.Warn().Err(err).Str("domain", originDomain).Msg("failed to create sized PNG for origin")
212 } else {
213 log.Debug().Str("domain", originDomain).Msg("created sized PNG for origin")
214 }
215 }()
216 }
217 }
218
219 go a.service.EnsureDiskCache(ctx, originDomain)
220 }
221 }
222}
223
224// PreloadFromCache attempts to load a favicon from cache without fetching.
225// Returns the texture if found in memory or disk cache, nil otherwise.
226func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {
227 domain := domainurl.ExtractDomain(pageURL)
228 if domain == "" {
229 return nil
230 }
231
232 // Check texture cache
233 if texture := a.GetTexture(domain); texture != nil {
234 return texture
235 }
236
237 // Check service cache (memory + disk)
238 if data, ok := a.service.GetCached(domain); ok && len(data) > 0 {
239 texture := bytesToTexture(data)
240 if texture != nil {
241 a.setTexture(domain, texture)
242 return texture
243 }
244 }
245
246 return nil
247}
248
249// Service returns the underlying favicon service.
250// Used by CLI components that need disk paths.
251func (a *FaviconAdapter) Service() *favicon.Service {
252 return a.service
253}
254
255// Close shuts down the adapter and underlying service.
256func (a *FaviconAdapter) Close() {
257 a.service.Close()
258}
259
260// Clear removes all entries from the texture cache.
261func (a *FaviconAdapter) Clear() {
262 a.mu.Lock()
263 a.textureCache = make(map[string]*gdk.Texture)
264 a.mu.Unlock()
265}
266
267// Size returns the number of entries in the texture cache.
268func (a *FaviconAdapter) Size() int {
269 a.mu.RLock()
270 size := len(a.textureCache)
271 a.mu.RUnlock()
272 return size
273}
274
275// setTexture stores a texture in the cache.
276func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
277 if domain == "" || texture == nil {
278 return
279 }
280 a.mu.Lock()
281 a.textureCache[domain] = texture
282 a.mu.Unlock()
283}
284
285// ensureSizedPNG ensures the sized PNG exists for CLI tools.
286// This is idempotent - it only creates the file if it doesn't exist.
287func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
288 if domain == "" {
289 return
290 }
291
292 log := logging.FromContext(ctx)
293
294 hasPNG := a.service.HasPNGOnDisk(domain)
295 hasSized := a.service.HasPNGSizedOnDisk(domain, favicon.NormalizedIconSize)
296
297 log.Debug().
298 Str("domain", domain).
299 Bool("has_png", hasPNG).
300 Bool("has_sized", hasSized).
301 Msg("checking sized PNG status")
302
303 // Only create if original PNG exists but sized version doesn't
304 if hasPNG && !hasSized {
305 log.Debug().Str("domain", domain).Msg("creating sized PNG")
306 if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
307 log.Warn().Err(err).Str("domain", domain).Msg("failed to create sized PNG")
308 } else {
309 log.Debug().Str("domain", domain).Msg("sized PNG created successfully")
310 }
311 }
312}
313
314// bytesToTexture converts raw favicon bytes to a GDK texture.
315func bytesToTexture(data []byte) *gdk.Texture {
316 if len(data) == 0 {
317 return nil
318 }
319
320 bytes := glib.NewBytes(data, uint(len(data)))
321 if bytes == nil {
322 return nil
323 }
324
325 texture, err := gdk.NewTextureFromBytes(bytes)
326 if err != nil {
327 return nil
328 }
329
330 return texture
331}