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