detached 2067abe

feat(favicon): add disk cache persistence for DuckDuckGo favicons

commit
2067abe4e9a4099615044cc2d56ab22e3a728bec
author
bnema · · 2025-12-16 21:57 UTC
parent
a6433c1
signature
unsigned
3 files · +189 -24
M internal/infrastructure/config/xdg.go +20 -0
@@ -22,6 +22,7 @@ type XDGDirs struct {
2222 ConfigHome string
2323 DataHome string
2424 StateHome string
25+ CacheHome string
2526 }
2627
2728 // GetXDGDirs returns the XDG Base Directory paths for dumber.
@@ -41,6 +42,7 @@ func GetXDGDirs() (*XDGDirs, error) {
4142 ConfigHome: devDir,
4243 DataHome: devDir,
4344 StateHome: devDir,
45+ CacheHome: devDir,
4446 }, nil
4547 }
4648
@@ -70,10 +72,18 @@ func GetXDGDirs() (*XDGDirs, error) {
7072 }
7173 stateHome = filepath.Join(stateHome, appName)
7274
75+ // XDG_CACHE_HOME
76+ cacheHome := os.Getenv("XDG_CACHE_HOME")
77+ if cacheHome == "" {
78+ cacheHome = filepath.Join(homeDir, ".cache")
79+ }
80+ cacheHome = filepath.Join(cacheHome, appName)
81+
7382 return &XDGDirs{
7483 ConfigHome: configHome,
7584 DataHome: dataHome,
7685 StateHome: stateHome,
86+ CacheHome: cacheHome,
7787 }, nil
7888 }
7989
@@ -153,6 +163,16 @@ func GetFilterCacheFile() (string, error) {
153163 return filepath.Join(cacheDir, "filters.cache"), nil
154164 }
155165
166+// GetFaviconCacheDir returns the XDG-compliant favicon cache directory for dumber.
167+// Favicon cache is stored in XDG_CACHE_HOME as it's transient data that can be refetched.
168+func GetFaviconCacheDir() (string, error) {
169+ dirs, err := GetXDGDirs()
170+ if err != nil {
171+ return "", err
172+ }
173+ return filepath.Join(dirs.CacheHome, "favicons"), nil
174+}
175+
156176 // EnsureDirectories creates the XDG directories if they don't exist.
157177 func EnsureDirectories() error {
158178 dirs, err := GetXDGDirs()
@@ -22,6 +22,7 @@ type XDGDirs struct {
22 ConfigHome string22 ConfigHome string
23 DataHome string23 DataHome string
24 StateHome string24 StateHome string
25+ CacheHome string
25 }26 }
26 27
27 // GetXDGDirs returns the XDG Base Directory paths for dumber.28 // GetXDGDirs returns the XDG Base Directory paths for dumber.
@@ -41,6 +42,7 @@ func GetXDGDirs() (*XDGDirs, error) {
41 ConfigHome: devDir,42 ConfigHome: devDir,
42 DataHome: devDir,43 DataHome: devDir,
43 StateHome: devDir,44 StateHome: devDir,
45+ CacheHome: devDir,
44 }, nil46 }, nil
45 }47 }
46 48
@@ -70,10 +72,18 @@ func GetXDGDirs() (*XDGDirs, error) {
70 }72 }
71 stateHome = filepath.Join(stateHome, appName)73 stateHome = filepath.Join(stateHome, appName)
72 74
75+ // XDG_CACHE_HOME
76+ cacheHome := os.Getenv("XDG_CACHE_HOME")
77+ if cacheHome == "" {
78+ cacheHome = filepath.Join(homeDir, ".cache")
79+ }
80+ cacheHome = filepath.Join(cacheHome, appName)
81+
73 return &XDGDirs{82 return &XDGDirs{
74 ConfigHome: configHome,83 ConfigHome: configHome,
75 DataHome: dataHome,84 DataHome: dataHome,
76 StateHome: stateHome,85 StateHome: stateHome,
86+ CacheHome: cacheHome,
77 }, nil87 }, nil
78 }88 }
79 89
@@ -153,6 +163,16 @@ func GetFilterCacheFile() (string, error) {
153 return filepath.Join(cacheDir, "filters.cache"), nil163 return filepath.Join(cacheDir, "filters.cache"), nil
154 }164 }
155 165
166+// GetFaviconCacheDir returns the XDG-compliant favicon cache directory for dumber.
167+// Favicon cache is stored in XDG_CACHE_HOME as it's transient data that can be refetched.
168+func GetFaviconCacheDir() (string, error) {
169+ dirs, err := GetXDGDirs()
170+ if err != nil {
171+ return "", err
172+ }
173+ return filepath.Join(dirs.CacheHome, "favicons"), nil
174+}
175+
156 // EnsureDirectories creates the XDG directories if they don't exist.176 // EnsureDirectories creates the XDG directories if they don't exist.
157 func EnsureDirectories() error {177 func EnsureDirectories() error {
158 dirs, err := GetXDGDirs()178 dirs, err := GetXDGDirs()
M internal/ui/app.go +3 -0
@@ -230,6 +230,9 @@ func (a *App) onShutdown(ctx context.Context) {
230230 a.cancel(errors.New("application shutdown"))
231231
232232 // Cleanup resources
233+ if a.faviconCache != nil {
234+ a.faviconCache.Close()
235+ }
233236 if a.deps.Pool != nil {
234237 a.deps.Pool.Close()
235238 }
@@ -230,6 +230,9 @@ func (a *App) onShutdown(ctx context.Context) {
230 a.cancel(errors.New("application shutdown"))230 a.cancel(errors.New("application shutdown"))
231 231
232 // Cleanup resources232 // Cleanup resources
233+ if a.faviconCache != nil {
234+ a.faviconCache.Close()
235+ }
233 if a.deps.Pool != nil {236 if a.deps.Pool != nil {
234 a.deps.Pool.Close()237 a.deps.Pool.Close()
235 }238 }
M internal/ui/cache/favicon_cache.go +166 -24
@@ -7,10 +7,13 @@ import (
77 "io"
88 "net/http"
99 "net/url"
10+ "os"
11+ "path/filepath"
1012 "strings"
1113 "sync"
1214 "time"
1315
16+ "github.com/bnema/dumber/internal/infrastructure/config"
1417 "github.com/bnema/dumber/internal/logging"
1518 "github.com/bnema/puregotk-webkit/webkit"
1619 "github.com/jwijenbergh/puregotk/v4/gdk"
@@ -23,29 +26,56 @@ const (
2326 duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico"
2427 // HTTP client timeout for favicon fetch
2528 faviconFetchTimeout = 5 * time.Second
29+ // diskWriteBufferSize defines the capacity of the favicon write channel
30+ diskWriteBufferSize = 100
31+ // File permissions for favicon cache
32+ diskCacheDirPerm = 0750
33+ diskCacheFilePerm = 0600
2634 )
2735
28-// FaviconCache provides a two-tier favicon caching system:
36+// diskWrite represents a favicon to be written to disk asynchronously.
37+type diskWrite struct {
38+ domain string
39+ data []byte
40+}
41+
42+// FaviconCache provides a multi-tier favicon caching system:
2943 // 1. In-memory cache keyed by domain for fast lookups
30-// 2. WebKit FaviconDatabase as persistent backing store
31-// 3. Google favicon API as fallback
44+// 2. Disk cache for persistence across restarts
45+// 3. WebKit FaviconDatabase as backing store
46+// 4. DuckDuckGo favicon API as fallback
3247 type FaviconCache struct {
33- mu sync.RWMutex
34- cache map[string]*gdk.Texture // key: domain (e.g., "github.com")
35- faviconDB *webkit.FaviconDatabase
36- client *http.Client
48+ mu sync.RWMutex
49+ cache map[string]*gdk.Texture // key: domain (e.g., "github.com")
50+ faviconDB *webkit.FaviconDatabase
51+ client *http.Client
52+ diskCacheDir string // path to favicon cache directory
53+ writeChan chan diskWrite // channel for async writes
54+ closeOnce sync.Once // ensures Close is called only once
3755 }
3856
3957 // NewFaviconCache creates a new FaviconCache with the given FaviconDatabase.
4058 // The faviconDB can be nil, in which case only the in-memory cache is used.
4159 func NewFaviconCache(faviconDB *webkit.FaviconDatabase) *FaviconCache {
42- return &FaviconCache{
43- cache: make(map[string]*gdk.Texture),
44- faviconDB: faviconDB,
60+ // Get disk cache directory (fail silently if error)
61+ diskCacheDir, _ := config.GetFaviconCacheDir()
62+
63+ fc := &FaviconCache{
64+ cache: make(map[string]*gdk.Texture),
65+ faviconDB: faviconDB,
66+ diskCacheDir: diskCacheDir,
67+ writeChan: make(chan diskWrite, diskWriteBufferSize),
4568 client: &http.Client{
4669 Timeout: faviconFetchTimeout,
4770 },
4871 }
72+
73+ // Start background writer goroutine
74+ if diskCacheDir != "" {
75+ go fc.diskWriter()
76+ }
77+
78+ return fc
4979 }
5080
5181 // extractDomain extracts the normalized domain (host) from a URL string.
@@ -118,6 +148,13 @@ func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback
118148 return
119149 }
120150
151+ // Check disk cache second
152+ if texture := fc.loadFromDisk(domain); texture != nil {
153+ fc.Set(domain, texture)
154+ callback(texture)
155+ return
156+ }
157+
121158 // Helper to invoke callback on main thread
122159 invokeCallback := func(texture *gdk.Texture) {
123160 cb := glib.SourceFunc(func(uintptr) bool {
@@ -130,9 +167,13 @@ func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback
130167 // Helper to fetch from external API and invoke callback
131168 fetchExternal := func() {
132169 go func() {
133- texture := fc.fetchFromExternal(ctx, domain)
170+ texture, rawData := fc.fetchFromExternal(ctx, domain)
134171 if texture != nil {
135172 fc.Set(domain, texture)
173+ // Queue async disk write
174+ if len(rawData) > 0 {
175+ fc.queueDiskWrite(domain, rawData)
176+ }
136177 }
137178 invokeCallback(texture)
138179 }()
@@ -170,61 +211,61 @@ func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback
170211 }
171212
172213 // fetchFromExternal fetches a favicon from DuckDuckGo's favicon API.
173-// Returns nil if fetch fails.
174-func (fc *FaviconCache) fetchFromExternal(ctx context.Context, domain string) *gdk.Texture {
214+// Returns the texture and raw bytes (bytes used for disk cache).
215+func (fc *FaviconCache) fetchFromExternal(ctx context.Context, domain string) (texture *gdk.Texture, rawData []byte) {
175216 log := logging.FromContext(ctx)
176217
177218 if domain == "" {
178- return nil
219+ return nil, nil
179220 }
180221
181222 faviconURL := fmt.Sprintf(duckduckgoFaviconURL, url.QueryEscape(domain))
182223 log.Debug().Str("url", faviconURL).Msg("fetching favicon from DuckDuckGo")
183224
184- req, err := http.NewRequestWithContext(ctx, http.MethodGet, faviconURL, nil)
225+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, faviconURL, http.NoBody)
185226 if err != nil {
186227 log.Debug().Err(err).Msg("failed to create favicon request")
187- return nil
228+ return nil, nil
188229 }
189230
190231 resp, err := fc.client.Do(req)
191232 if err != nil {
192233 log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo")
193- return nil
234+ return nil, nil
194235 }
195236 defer resp.Body.Close()
196237
197238 if resp.StatusCode != http.StatusOK {
198239 log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status")
199- return nil
240+ return nil, nil
200241 }
201242
202243 data, err := io.ReadAll(resp.Body)
203244 if err != nil {
204245 log.Debug().Err(err).Str("domain", domain).Msg("failed to read favicon response")
205- return nil
246+ return nil, nil
206247 }
207248
208249 if len(data) == 0 {
209250 log.Debug().Str("domain", domain).Msg("empty favicon response from DuckDuckGo")
210- return nil
251+ return nil, nil
211252 }
212253
213254 // Convert bytes to GdkTexture
214255 bytes := glib.NewBytes(data, uint(len(data)))
215256 if bytes == nil {
216257 log.Debug().Str("domain", domain).Msg("failed to create GBytes from favicon data")
217- return nil
258+ return nil, nil
218259 }
219260
220- texture, err := gdk.NewTextureFromBytes(bytes)
261+ texture, err = gdk.NewTextureFromBytes(bytes)
221262 if err != nil {
222263 log.Debug().Err(err).Str("domain", domain).Msg("failed to create texture from favicon bytes")
223- return nil
264+ return nil, nil
224265 }
225266
226267 log.Debug().Str("domain", domain).Msg("favicon fetched from DuckDuckGo and cached")
227- return texture
268+ return texture, data
228269 }
229270
230271 // Clear removes all entries from the in-memory cache.
@@ -241,3 +282,104 @@ func (fc *FaviconCache) Size() int {
241282 fc.mu.RUnlock()
242283 return size
243284 }
285+
286+// Close stops the background writer goroutine.
287+func (fc *FaviconCache) Close() {
288+ fc.closeOnce.Do(func() {
289+ if fc.writeChan != nil {
290+ close(fc.writeChan)
291+ }
292+ })
293+}
294+
295+// sanitizeDomainForFilename converts a domain to a safe filename.
296+func sanitizeDomainForFilename(domain string) string {
297+ replacer := strings.NewReplacer(
298+ ":", "_",
299+ "/", "_",
300+ "\\", "_",
301+ "*", "_",
302+ "?", "_",
303+ "\"", "_",
304+ "<", "_",
305+ ">", "_",
306+ "|", "_",
307+ )
308+ return replacer.Replace(domain) + ".ico"
309+}
310+
311+// loadFromDisk attempts to load a favicon from disk cache.
312+// Returns nil if not found or on error.
313+func (fc *FaviconCache) loadFromDisk(domain string) *gdk.Texture {
314+ if fc.diskCacheDir == "" {
315+ return nil
316+ }
317+
318+ filename := sanitizeDomainForFilename(domain)
319+ path := filepath.Join(fc.diskCacheDir, filename)
320+
321+ //nolint:gosec // path is constructed from sanitized domain, not user input
322+ data, err := os.ReadFile(path)
323+ if err != nil {
324+ return nil
325+ }
326+
327+ if len(data) == 0 {
328+ return nil
329+ }
330+
331+ bytes := glib.NewBytes(data, uint(len(data)))
332+ if bytes == nil {
333+ return nil
334+ }
335+
336+ texture, err := gdk.NewTextureFromBytes(bytes)
337+ if err != nil {
338+ return nil
339+ }
340+
341+ return texture
342+}
343+
344+// writeToDisk atomically writes favicon data to disk.
345+func (fc *FaviconCache) writeToDisk(domain string, data []byte) {
346+ if fc.diskCacheDir == "" || len(data) == 0 {
347+ return
348+ }
349+
350+ // Ensure directory exists (lazy initialization)
351+ if err := os.MkdirAll(fc.diskCacheDir, diskCacheDirPerm); err != nil {
352+ return
353+ }
354+
355+ filename := sanitizeDomainForFilename(domain)
356+ finalPath := filepath.Join(fc.diskCacheDir, filename)
357+ tempPath := finalPath + ".tmp"
358+
359+ // Write to temp file
360+ if err := os.WriteFile(tempPath, data, diskCacheFilePerm); err != nil {
361+ return
362+ }
363+
364+ // Atomic rename
365+ if err := os.Rename(tempPath, finalPath); err != nil {
366+ _ = os.Remove(tempPath)
367+ }
368+}
369+
370+// queueDiskWrite sends favicon data to be written asynchronously.
371+func (fc *FaviconCache) queueDiskWrite(domain string, data []byte) {
372+ select {
373+ case fc.writeChan <- diskWrite{domain: domain, data: data}:
374+ // queued successfully
375+ default:
376+ // channel full, skip write
377+ }
378+}
379+
380+// diskWriter processes async write requests.
381+func (fc *FaviconCache) diskWriter() {
382+ for write := range fc.writeChan {
383+ fc.writeToDisk(write.domain, write.data)
384+ }
385+}
@@ -7,10 +7,13 @@ import (
7 "io"7 "io"
8 "net/http"8 "net/http"
9 "net/url"9 "net/url"
10+ "os"
11+ "path/filepath"
10 "strings"12 "strings"
11 "sync"13 "sync"
12 "time"14 "time"
13 15
16+ "github.com/bnema/dumber/internal/infrastructure/config"
14 "github.com/bnema/dumber/internal/logging"17 "github.com/bnema/dumber/internal/logging"
15 "github.com/bnema/puregotk-webkit/webkit"18 "github.com/bnema/puregotk-webkit/webkit"
16 "github.com/jwijenbergh/puregotk/v4/gdk"19 "github.com/jwijenbergh/puregotk/v4/gdk"
@@ -23,29 +26,56 @@ const (
23 duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico"26 duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico"
24 // HTTP client timeout for favicon fetch27 // HTTP client timeout for favicon fetch
25 faviconFetchTimeout = 5 * time.Second28 faviconFetchTimeout = 5 * time.Second
29+ // diskWriteBufferSize defines the capacity of the favicon write channel
30+ diskWriteBufferSize = 100
31+ // File permissions for favicon cache
32+ diskCacheDirPerm = 0750
33+ diskCacheFilePerm = 0600
26 )34 )
27 35
28-// FaviconCache provides a two-tier favicon caching system:36+// diskWrite represents a favicon to be written to disk asynchronously.
37+type diskWrite struct {
38+ domain string
39+ data []byte
40+}
41+
42+// FaviconCache provides a multi-tier favicon caching system:
29 // 1. In-memory cache keyed by domain for fast lookups43 // 1. In-memory cache keyed by domain for fast lookups
30-// 2. WebKit FaviconDatabase as persistent backing store44+// 2. Disk cache for persistence across restarts
31-// 3. Google favicon API as fallback45+// 3. WebKit FaviconDatabase as backing store
46+// 4. DuckDuckGo favicon API as fallback
32 type FaviconCache struct {47 type FaviconCache struct {
33- mu sync.RWMutex48+ mu sync.RWMutex
34- cache map[string]*gdk.Texture // key: domain (e.g., "github.com")49+ cache map[string]*gdk.Texture // key: domain (e.g., "github.com")
35- faviconDB *webkit.FaviconDatabase50+ faviconDB *webkit.FaviconDatabase
36- client *http.Client51+ client *http.Client
52+ diskCacheDir string // path to favicon cache directory
53+ writeChan chan diskWrite // channel for async writes
54+ closeOnce sync.Once // ensures Close is called only once
37 }55 }
38 56
39 // NewFaviconCache creates a new FaviconCache with the given FaviconDatabase.57 // NewFaviconCache creates a new FaviconCache with the given FaviconDatabase.
40 // The faviconDB can be nil, in which case only the in-memory cache is used.58 // The faviconDB can be nil, in which case only the in-memory cache is used.
41 func NewFaviconCache(faviconDB *webkit.FaviconDatabase) *FaviconCache {59 func NewFaviconCache(faviconDB *webkit.FaviconDatabase) *FaviconCache {
42- return &FaviconCache{60+ // Get disk cache directory (fail silently if error)
43- cache: make(map[string]*gdk.Texture),61+ diskCacheDir, _ := config.GetFaviconCacheDir()
44- faviconDB: faviconDB,62+
63+ fc := &FaviconCache{
64+ cache: make(map[string]*gdk.Texture),
65+ faviconDB: faviconDB,
66+ diskCacheDir: diskCacheDir,
67+ writeChan: make(chan diskWrite, diskWriteBufferSize),
45 client: &http.Client{68 client: &http.Client{
46 Timeout: faviconFetchTimeout,69 Timeout: faviconFetchTimeout,
47 },70 },
48 }71 }
72+
73+ // Start background writer goroutine
74+ if diskCacheDir != "" {
75+ go fc.diskWriter()
76+ }
77+
78+ return fc
49 }79 }
50 80
51 // extractDomain extracts the normalized domain (host) from a URL string.81 // extractDomain extracts the normalized domain (host) from a URL string.
@@ -118,6 +148,13 @@ func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback
118 return148 return
119 }149 }
120 150
151+ // Check disk cache second
152+ if texture := fc.loadFromDisk(domain); texture != nil {
153+ fc.Set(domain, texture)
154+ callback(texture)
155+ return
156+ }
157+
121 // Helper to invoke callback on main thread158 // Helper to invoke callback on main thread
122 invokeCallback := func(texture *gdk.Texture) {159 invokeCallback := func(texture *gdk.Texture) {
123 cb := glib.SourceFunc(func(uintptr) bool {160 cb := glib.SourceFunc(func(uintptr) bool {
@@ -130,9 +167,13 @@ func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback
130 // Helper to fetch from external API and invoke callback167 // Helper to fetch from external API and invoke callback
131 fetchExternal := func() {168 fetchExternal := func() {
132 go func() {169 go func() {
133- texture := fc.fetchFromExternal(ctx, domain)170+ texture, rawData := fc.fetchFromExternal(ctx, domain)
134 if texture != nil {171 if texture != nil {
135 fc.Set(domain, texture)172 fc.Set(domain, texture)
173+ // Queue async disk write
174+ if len(rawData) > 0 {
175+ fc.queueDiskWrite(domain, rawData)
176+ }
136 }177 }
137 invokeCallback(texture)178 invokeCallback(texture)
138 }()179 }()
@@ -170,61 +211,61 @@ func (fc *FaviconCache) GetOrFetch(ctx context.Context, pageURL string, callback
170 }211 }
171 212
172 // fetchFromExternal fetches a favicon from DuckDuckGo's favicon API.213 // fetchFromExternal fetches a favicon from DuckDuckGo's favicon API.
173-// Returns nil if fetch fails.214+// Returns the texture and raw bytes (bytes used for disk cache).
174-func (fc *FaviconCache) fetchFromExternal(ctx context.Context, domain string) *gdk.Texture {215+func (fc *FaviconCache) fetchFromExternal(ctx context.Context, domain string) (texture *gdk.Texture, rawData []byte) {
175 log := logging.FromContext(ctx)216 log := logging.FromContext(ctx)
176 217
177 if domain == "" {218 if domain == "" {
178- return nil219+ return nil, nil
179 }220 }
180 221
181 faviconURL := fmt.Sprintf(duckduckgoFaviconURL, url.QueryEscape(domain))222 faviconURL := fmt.Sprintf(duckduckgoFaviconURL, url.QueryEscape(domain))
182 log.Debug().Str("url", faviconURL).Msg("fetching favicon from DuckDuckGo")223 log.Debug().Str("url", faviconURL).Msg("fetching favicon from DuckDuckGo")
183 224
184- req, err := http.NewRequestWithContext(ctx, http.MethodGet, faviconURL, nil)225+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, faviconURL, http.NoBody)
185 if err != nil {226 if err != nil {
186 log.Debug().Err(err).Msg("failed to create favicon request")227 log.Debug().Err(err).Msg("failed to create favicon request")
187- return nil228+ return nil, nil
188 }229 }
189 230
190 resp, err := fc.client.Do(req)231 resp, err := fc.client.Do(req)
191 if err != nil {232 if err != nil {
192 log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo")233 log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo")
193- return nil234+ return nil, nil
194 }235 }
195 defer resp.Body.Close()236 defer resp.Body.Close()
196 237
197 if resp.StatusCode != http.StatusOK {238 if resp.StatusCode != http.StatusOK {
198 log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status")239 log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status")
199- return nil240+ return nil, nil
200 }241 }
201 242
202 data, err := io.ReadAll(resp.Body)243 data, err := io.ReadAll(resp.Body)
203 if err != nil {244 if err != nil {
204 log.Debug().Err(err).Str("domain", domain).Msg("failed to read favicon response")245 log.Debug().Err(err).Str("domain", domain).Msg("failed to read favicon response")
205- return nil246+ return nil, nil
206 }247 }
207 248
208 if len(data) == 0 {249 if len(data) == 0 {
209 log.Debug().Str("domain", domain).Msg("empty favicon response from DuckDuckGo")250 log.Debug().Str("domain", domain).Msg("empty favicon response from DuckDuckGo")
210- return nil251+ return nil, nil
211 }252 }
212 253
213 // Convert bytes to GdkTexture254 // Convert bytes to GdkTexture
214 bytes := glib.NewBytes(data, uint(len(data)))255 bytes := glib.NewBytes(data, uint(len(data)))
215 if bytes == nil {256 if bytes == nil {
216 log.Debug().Str("domain", domain).Msg("failed to create GBytes from favicon data")257 log.Debug().Str("domain", domain).Msg("failed to create GBytes from favicon data")
217- return nil258+ return nil, nil
218 }259 }
219 260
220- texture, err := gdk.NewTextureFromBytes(bytes)261+ texture, err = gdk.NewTextureFromBytes(bytes)
221 if err != nil {262 if err != nil {
222 log.Debug().Err(err).Str("domain", domain).Msg("failed to create texture from favicon bytes")263 log.Debug().Err(err).Str("domain", domain).Msg("failed to create texture from favicon bytes")
223- return nil264+ return nil, nil
224 }265 }
225 266
226 log.Debug().Str("domain", domain).Msg("favicon fetched from DuckDuckGo and cached")267 log.Debug().Str("domain", domain).Msg("favicon fetched from DuckDuckGo and cached")
227- return texture268+ return texture, data
228 }269 }
229 270
230 // Clear removes all entries from the in-memory cache.271 // Clear removes all entries from the in-memory cache.
@@ -241,3 +282,104 @@ func (fc *FaviconCache) Size() int {
241 fc.mu.RUnlock()282 fc.mu.RUnlock()
242 return size283 return size
243 }284 }
285+
286+// Close stops the background writer goroutine.
287+func (fc *FaviconCache) Close() {
288+ fc.closeOnce.Do(func() {
289+ if fc.writeChan != nil {
290+ close(fc.writeChan)
291+ }
292+ })
293+}
294+
295+// sanitizeDomainForFilename converts a domain to a safe filename.
296+func sanitizeDomainForFilename(domain string) string {
297+ replacer := strings.NewReplacer(
298+ ":", "_",
299+ "/", "_",
300+ "\\", "_",
301+ "*", "_",
302+ "?", "_",
303+ "\"", "_",
304+ "<", "_",
305+ ">", "_",
306+ "|", "_",
307+ )
308+ return replacer.Replace(domain) + ".ico"
309+}
310+
311+// loadFromDisk attempts to load a favicon from disk cache.
312+// Returns nil if not found or on error.
313+func (fc *FaviconCache) loadFromDisk(domain string) *gdk.Texture {
314+ if fc.diskCacheDir == "" {
315+ return nil
316+ }
317+
318+ filename := sanitizeDomainForFilename(domain)
319+ path := filepath.Join(fc.diskCacheDir, filename)
320+
321+ //nolint:gosec // path is constructed from sanitized domain, not user input
322+ data, err := os.ReadFile(path)
323+ if err != nil {
324+ return nil
325+ }
326+
327+ if len(data) == 0 {
328+ return nil
329+ }
330+
331+ bytes := glib.NewBytes(data, uint(len(data)))
332+ if bytes == nil {
333+ return nil
334+ }
335+
336+ texture, err := gdk.NewTextureFromBytes(bytes)
337+ if err != nil {
338+ return nil
339+ }
340+
341+ return texture
342+}
343+
344+// writeToDisk atomically writes favicon data to disk.
345+func (fc *FaviconCache) writeToDisk(domain string, data []byte) {
346+ if fc.diskCacheDir == "" || len(data) == 0 {
347+ return
348+ }
349+
350+ // Ensure directory exists (lazy initialization)
351+ if err := os.MkdirAll(fc.diskCacheDir, diskCacheDirPerm); err != nil {
352+ return
353+ }
354+
355+ filename := sanitizeDomainForFilename(domain)
356+ finalPath := filepath.Join(fc.diskCacheDir, filename)
357+ tempPath := finalPath + ".tmp"
358+
359+ // Write to temp file
360+ if err := os.WriteFile(tempPath, data, diskCacheFilePerm); err != nil {
361+ return
362+ }
363+
364+ // Atomic rename
365+ if err := os.Rename(tempPath, finalPath); err != nil {
366+ _ = os.Remove(tempPath)
367+ }
368+}
369+
370+// queueDiskWrite sends favicon data to be written asynchronously.
371+func (fc *FaviconCache) queueDiskWrite(domain string, data []byte) {
372+ select {
373+ case fc.writeChan <- diskWrite{domain: domain, data: data}:
374+ // queued successfully
375+ default:
376+ // channel full, skip write
377+ }
378+}
379+
380+// diskWriter processes async write requests.
381+func (fc *FaviconCache) diskWriter() {
382+ for write := range fc.writeChan {
383+ fc.writeToDisk(write.domain, write.data)
384+ }
385+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close