detached 8a0ce09

feat(favicon): add cache refresh resolution pipeline

commit
8a0ce09ffd1c41b35c6fd1dd7fb78231039e0233
author
brice · · 2026-06-07 18:17 UTC
parent
5a2fbe6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
51 files · +3665 -362
M cmd/dumber/main.go +55 -23
@@ -26,7 +26,7 @@ import (
2626 "github.com/bnema/dumber/internal/infrastructure/deps"
2727 "github.com/bnema/dumber/internal/infrastructure/desktop"
2828 renderenv "github.com/bnema/dumber/internal/infrastructure/env"
29- "github.com/bnema/dumber/internal/infrastructure/favicon"
29+ infrafavicon "github.com/bnema/dumber/internal/infrastructure/favicon"
3030 "github.com/bnema/dumber/internal/infrastructure/filesystem"
3131 "github.com/bnema/dumber/internal/infrastructure/idle"
3232 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
@@ -688,6 +688,7 @@ type repositories struct {
688688 permission port.PermissionRepository
689689 session repository.SessionRepository
690690 sessionState repository.SessionStateRepository
691+ faviconRepo port.FaviconRepository
691692 }
692693
693694 func createRepositories(db *sql.DB) *repositories {
@@ -700,6 +701,7 @@ func createRepositories(db *sql.DB) *repositories {
700701 permission: sqlite.NewPermissionRepository(db),
701702 session: sqlite.NewSessionRepository(db),
702703 sessionState: sqlite.NewSessionStateRepository(db),
704+ faviconRepo: sqlite.NewFaviconRepository(db),
703705 }
704706 }
705707
@@ -713,6 +715,7 @@ func createLazyRepositories(provider port.DatabaseProvider) *repositories {
713715 permission: sqlite.NewLazyPermissionRepository(provider),
714716 session: sqlite.NewLazySessionRepository(provider),
715717 sessionState: sqlite.NewLazySessionStateRepository(provider),
718+ faviconRepo: sqlite.NewLazyFaviconRepository(provider),
716719 }
717720 }
718721
@@ -731,7 +734,8 @@ type useCases struct {
731734 checkUpdate *usecase.CheckUpdateUseCase
732735 applyUpdate *usecase.ApplyUpdateUseCase
733736 clipboard port.Clipboard
734- favicon *favicon.Service
737+ favicon *infrafavicon.Service
738+ faviconUC *usecase.FaviconUseCase
735739 }
736740
737741 func createUseCases(repos *repositories, cfg *config.Config) *useCases {
@@ -744,6 +748,17 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
744748
745749 clipboardAdapter := clipboard.New()
746750 faviconCacheDir, _ := config.GetFaviconCacheDir()
751+ faviconService := infrafavicon.NewService(faviconCacheDir)
752+ var faviconUC *usecase.FaviconUseCase
753+ if repos.faviconRepo != nil {
754+ faviconUC = usecase.NewFaviconUseCase(usecase.FaviconDeps{
755+ Repository: repos.faviconRepo,
756+ BlobStore: infrafavicon.NewBlobStoreFromCache(faviconService.Cache()),
757+ Converter: infrafavicon.NewImageConverter(),
758+ Fetcher: infrafavicon.NewFetcher(),
759+ Scheduler: infrafavicon.NewRefreshScheduler(),
760+ })
761+ }
747762 xdgDirs, _ := config.GetXDGDirs()
748763 stateDir, _ := config.GetStateDir()
749764 defaultZoom := cfg.DefaultWebpageZoom
@@ -776,7 +791,8 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
776791 checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),
777792 applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),
778793 clipboard: clipboardAdapter,
779- favicon: favicon.NewService(faviconCacheDir),
794+ favicon: faviconService,
795+ faviconUC: faviconUC,
780796 }
781797 }
782798
@@ -799,6 +815,20 @@ func isCEFSubprocess(args []string) bool {
799815 !strings.HasPrefix(args[2], "-")
800816 }
801817
818+func faviconResolver(uc *useCases) port.FaviconResolver {
819+ if uc == nil || uc.faviconUC == nil {
820+ return nil
821+ }
822+ return uc.faviconUC
823+}
824+
825+func legacyFaviconService(uc *useCases) port.FaviconService {
826+ if uc == nil || uc.faviconUC != nil {
827+ return nil
828+ }
829+ return uc.favicon
830+}
831+
802832 func buildUIDependencies(
803833 ctx context.Context,
804834 cfg *config.Config,
@@ -839,27 +869,29 @@ func buildUIDependencies(
839869 runtimeProfile.Mode == runtimeprofile.ModeDev,
840870 bootstrap.ResolveXDGRuntimeDir(runtimeProfile),
841871 ),
842- Engine: engine,
843- FilterManager: filterManager,
844- HistoryRepo: repos.history,
845- FavoriteRepo: repos.favorite,
846- ZoomRepo: repos.zoom,
847- PermissionRepo: repos.permission,
848- TabsUC: uc.tabs,
849- PanesUC: uc.panes,
850- HistoryUC: uc.history,
851- FavoritesUC: uc.favorites,
852- ZoomUC: uc.zoom,
853- PermissionUC: uc.permission,
854- NavigateUC: uc.navigate,
855- CopyURLUC: uc.copyURL,
856- Clipboard: uc.clipboard,
857- FaviconService: uc.favicon,
872+ Engine: engine,
873+ FilterManager: filterManager,
874+ HistoryRepo: repos.history,
875+ FavoriteRepo: repos.favorite,
876+ ZoomRepo: repos.zoom,
877+ PermissionRepo: repos.permission,
878+ TabsUC: uc.tabs,
879+ PanesUC: uc.panes,
880+ HistoryUC: uc.history,
881+ FavoritesUC: uc.favorites,
882+ ZoomUC: uc.zoom,
883+ PermissionUC: uc.permission,
884+ NavigateUC: uc.navigate,
885+ CopyURLUC: uc.copyURL,
886+ Clipboard: uc.clipboard,
887+ FaviconService: legacyFaviconService(uc),
888+ FaviconResolver: faviconResolver(uc),
889+ SystemviewFaviconResolver: faviconResolver(uc),
858890 FaviconAdapterConfig: adapter.FaviconAdapterConfig{
859- IsInternalURL: favicon.IsInternalURL,
860- InternalDomain: favicon.InternalDomain,
861- NormalizedIconSize: favicon.NormalizedIconSize,
862- GetLogoBytes: favicon.GetLogoBytes,
891+ IsInternalURL: infrafavicon.IsInternalURL,
892+ InternalDomain: infrafavicon.InternalDomain,
893+ NormalizedIconSize: infrafavicon.NormalizedIconSize,
894+ GetLogoBytes: infrafavicon.GetLogoBytes,
863895 },
864896 IdleInhibitor: idleInhibitor,
865897 SessionRepo: repos.session,
M cmd/dumber/main_test.go +15 -0
@@ -73,6 +73,21 @@ func TestLaunchModeFromArgs_OmniboxFlagFallsBackToCLI(t *testing.T) {
7373 }
7474 }
7575
76+func TestCreateLazyRepositoriesIncludesFaviconRepo(t *testing.T) {
77+ provider := mocks.NewMockDatabaseProvider(t)
78+ repos := createLazyRepositories(provider)
79+ if repos.faviconRepo == nil {
80+ t.Fatal("expected lazy favicon repository to be wired")
81+ }
82+}
83+
84+func TestFaviconResolverNilHelperAvoidsTypedNilInterface(t *testing.T) {
85+ var resolver any = faviconResolver(&useCases{})
86+ if resolver != nil {
87+ t.Fatalf("expected nil interface-safe resolver, got %#v", resolver)
88+ }
89+}
90+
7691 func TestIsCEFSubprocess(t *testing.T) {
7792 tests := []struct {
7893 name string
M go.mod +1 -1
@@ -23,6 +23,7 @@ require (
2323 github.com/spf13/viper v1.21.0
2424 github.com/stretchr/testify v1.11.1
2525 golang.org/x/image v0.41.0
26+ golang.org/x/net v0.55.0
2627 golang.org/x/sys v0.45.0
2728 gopkg.in/yaml.v3 v3.0.1
2829 )
@@ -74,7 +75,6 @@ require (
7475 go.yaml.in/yaml/v3 v3.0.4 // indirect
7576 golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
7677 golang.org/x/mod v0.36.0 // indirect
77- golang.org/x/net v0.54.0 // indirect
7878 golang.org/x/sync v0.20.0 // indirect
7979 golang.org/x/text v0.37.0 // indirect
8080 golang.org/x/tools v0.45.0 // indirect
M go.sum +2 -2 generated file: collapsed
A internal/application/port/favicon_blob_store.go +17 -0
@@ -0,0 +1,17 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/favicon"
7+)
8+
9+type FaviconBlobStore interface {
10+ ReadOriginal(ctx context.Context, key favicon.Key) ([]byte, string, error)
11+ WriteOriginal(ctx context.Context, key favicon.Key, data []byte, contentType string) error
12+ ReadPNG(ctx context.Context, key favicon.Key) ([]byte, string, error)
13+ WritePNG(ctx context.Context, key favicon.Key, data []byte) error
14+ ReadSizedPNG(ctx context.Context, key favicon.Key, size int) ([]byte, string, error)
15+ WriteSizedPNG(ctx context.Context, key favicon.Key, size int, data []byte) error
16+ RemoveDerived(ctx context.Context, key favicon.Key) error
17+}
A internal/application/port/favicon_converter.go +12 -0
@@ -0,0 +1,12 @@
1+package port
2+
3+import "context"
4+
5+type ConvertedFavicon struct {
6+ PNG []byte
7+ SizedPNG map[int][]byte
8+}
9+
10+type FaviconImageConverter interface {
11+ Convert(ctx context.Context, original []byte, contentType string, sizes []int) (*ConvertedFavicon, error)
12+}
A internal/application/port/favicon_discoverer.go +12 -0
@@ -0,0 +1,12 @@
1+package port
2+
3+import "context"
4+
5+type FaviconDiscovery struct {
6+ PageURL string
7+ IconURLs []string
8+}
9+
10+type FaviconDiscoverer interface {
11+ Discover(ctx context.Context, pageURL string) (*FaviconDiscovery, error)
12+}
A internal/application/port/favicon_errors.go +9 -0
@@ -0,0 +1,9 @@
1+package port
2+
3+import "errors"
4+
5+// ErrFaviconMiss is returned when a favicon cannot be found or served from allowed sources.
6+var ErrFaviconMiss = errors.New("favicon miss")
7+
8+// ErrFaviconInvalidInput is returned when favicon storage receives invalid input.
9+var ErrFaviconInvalidInput = errors.New("invalid favicon input")
A internal/application/port/favicon_fetcher.go +30 -0
@@ -0,0 +1,30 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/favicon"
7+)
8+
9+// FaviconFetchRequest specifies the page and optional icon URL for a favicon fetch.
10+// PageURL is the absolute source page URL; IconURL may be empty when the fetcher should discover or fall back.
11+type FaviconFetchRequest struct {
12+ PageURL string
13+ IconURL string
14+}
15+
16+// FaviconFetchedIcon contains fetched favicon bytes and their source metadata.
17+// PageURL is the source page, IconURL is the resource URL, Source identifies discovery origin, and ContentType is the MIME type.
18+type FaviconFetchedIcon struct {
19+ PageURL string
20+ IconURL string
21+ Bytes []byte
22+ Source favicon.Source
23+ ContentType string
24+}
25+
26+// FaviconFetcher safely fetches favicon resources for a FaviconFetchRequest.
27+// Implementations validate URLs/content and return ErrFaviconMiss for not-found or disallowed resources.
28+type FaviconFetcher interface {
29+ Fetch(ctx context.Context, request FaviconFetchRequest) (*FaviconFetchedIcon, error)
30+}
A internal/application/port/favicon_invalidator.go +22 -0
@@ -0,0 +1,22 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/favicon"
7+)
8+
9+// FaviconInvalidator clears cached or derived data for a single favicon key.
10+type FaviconInvalidator interface {
11+ Invalidate(ctx context.Context, key favicon.Key) error
12+}
13+
14+// FaviconInvalidators coordinates invalidation across registered invalidators.
15+type FaviconInvalidators interface {
16+ InvalidateAll(ctx context.Context, key favicon.Key) error
17+}
18+
19+// FaviconInvalidatorRegistry registers invalidators during application initialization.
20+type FaviconInvalidatorRegistry interface {
21+ RegisterFaviconInvalidator(invalidator FaviconInvalidator)
22+}
A internal/application/port/favicon_repository.go +19 -0
@@ -0,0 +1,19 @@
1+package port
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/favicon"
8+)
9+
10+// FaviconRepository persists favicon metadata and supports fallback-key lookups.
11+// Get returns metadata for one key, FindFirst returns the first matching key, Upsert writes metadata,
12+// UpdateLastChecked records refresh state, and Delete removes metadata.
13+type FaviconRepository interface {
14+ Get(ctx context.Context, key favicon.Key) (*favicon.Metadata, error)
15+ FindFirst(ctx context.Context, keys []favicon.Key) (*favicon.Metadata, error)
16+ Upsert(ctx context.Context, meta favicon.Metadata) error
17+ UpdateLastChecked(ctx context.Context, key favicon.Key, contentHash string, checkedAt time.Time) error
18+ Delete(ctx context.Context, key favicon.Key) error
19+}
A internal/application/port/favicon_resolver.go +60 -0
@@ -0,0 +1,60 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/favicon"
7+)
8+
9+// FaviconResolvePurpose identifies the context requesting favicon resolution.
10+type FaviconResolvePurpose string
11+
12+const (
13+ // FaviconResolvePurposeUI resolves favicons for end-user UI display.
14+ FaviconResolvePurposeUI FaviconResolvePurpose = "ui"
15+ // FaviconResolvePurposeSystemview resolves favicons for internal views without remote loads.
16+ FaviconResolvePurposeSystemview FaviconResolvePurpose = "systemview"
17+ // FaviconResolvePurposeRefresh resolves favicons for background refresh work.
18+ FaviconResolvePurposeRefresh FaviconResolvePurpose = "refresh"
19+)
20+
21+// FaviconResolveOptions controls cache lookup, blocking refresh, background refresh, and caller intent.
22+type FaviconResolveOptions struct {
23+ AllowBlockingRefresh bool
24+ ScheduleBackgroundRefresh bool
25+ Purpose FaviconResolvePurpose
26+}
27+
28+// ResolvedFavicon is a fully resolved favicon payload with metadata and refresh state.
29+// Key identifies the cache entry; Bytes and ContentType hold image data; Metadata and BackgroundRefreshScheduled describe state.
30+type ResolvedFavicon struct {
31+ Key favicon.Key
32+ Bytes []byte
33+ ContentType string
34+ Metadata *favicon.Metadata
35+ BackgroundRefreshScheduled bool
36+}
37+
38+// FaviconResolver orchestrates favicon lookup, observation, refresh, sizing, and invalidation.
39+// It extends FaviconSystemviewResolver for boundary-safe systemview reads.
40+type FaviconResolver interface {
41+ FaviconSystemviewResolver
42+ Resolve(ctx context.Context, rawURLOrDomain string, size int, options FaviconResolveOptions) (*ResolvedFavicon, error)
43+ Observe(ctx context.Context, pageURL, iconURL string, bytes []byte, source favicon.Source, contentType string) (*favicon.Metadata, error)
44+ RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string) error
45+ EnsureSized(ctx context.Context, key favicon.Key, size int) error
46+ Invalidate(ctx context.Context, key favicon.Key) error
47+}
48+
49+// FaviconSystemviewResolver is the boundary-safe API used by engine scheme
50+// handlers. Implementations must resolve exact keys and parent fallbacks through
51+// the application usecase and must not trigger remote network loads.
52+type FaviconSystemviewResolver interface {
53+ ResolveSystemviewIcon(ctx context.Context, rawDomain string, size int) (*ResolvedFavicon, error)
54+}
55+
56+// SystemviewFaviconResolverSetter is an optional engine capability that wires
57+// the application favicon resolver to internal systemview pages.
58+type SystemviewFaviconResolverSetter interface {
59+ SetSystemviewFaviconResolver(resolver FaviconSystemviewResolver)
60+}
A internal/application/port/favicon_scheduler.go +13 -0
@@ -0,0 +1,13 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/favicon"
7+)
8+
9+// FaviconRefreshScheduler deduplicates and runs asynchronous favicon refresh work.
10+// Schedule returns true when work is queued and false for duplicate or rejected work.
11+type FaviconRefreshScheduler interface {
12+ Schedule(ctx context.Context, key favicon.Key, work func(context.Context)) bool
13+}
M internal/application/port/favicon_service.go +0 -6
@@ -18,9 +18,3 @@ type FaviconService interface {
1818 EnsureDiskCache(ctx context.Context, domain string)
1919 Close()
2020 }
21-
22-// SystemviewFaviconServiceSetter is an optional engine capability that lets the
23-// UI expose cached favicons to internal systemview pages without remote image loads.
24-type SystemviewFaviconServiceSetter interface {
25- SetSystemviewFaviconService(service FaviconService)
26-}
M internal/application/port/webview.go +2 -0
@@ -101,6 +101,8 @@ type WebViewCallbacks struct {
101101 // OnFaviconChanged is called when the page favicon changes.
102102 // The parameter is a *gdk.Texture (passed as Texture interface to avoid GTK import in port layer).
103103 OnFaviconChanged func(favicon Texture)
104+ // OnFaviconURLChanged is called when the engine discovers ordered favicon URL candidates for a page.
105+ OnFaviconURLChanged func(pageURL string, iconURLs []string)
104106 // OnClose is called when the WebView requests to close.
105107 OnClose func()
106108 // OnCreate is called when a popup window is requested.
A internal/application/usecase/favicon.go +401 -0
@@ -0,0 +1,401 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+ "time"
9+
10+ appport "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/favicon"
12+)
13+
14+var ErrFaviconMiss = appport.ErrFaviconMiss
15+
16+type ResolvePurpose = appport.FaviconResolvePurpose
17+
18+const (
19+ ResolvePurposeUI = appport.FaviconResolvePurposeUI
20+ ResolvePurposeSystemview = appport.FaviconResolvePurposeSystemview
21+ ResolvePurposeRefresh = appport.FaviconResolvePurposeRefresh
22+)
23+
24+type ResolveOptions = appport.FaviconResolveOptions
25+type ResolvedFavicon = appport.ResolvedFavicon
26+
27+// SystemviewIconSize is the fixed PNG size served to internal systemview pages.
28+const SystemviewIconSize = 32
29+
30+type faviconInvalidatorRegistry struct {
31+ mu sync.RWMutex
32+ fallback appport.FaviconInvalidators
33+ invalidators []appport.FaviconInvalidator
34+}
35+
36+func (r *faviconInvalidatorRegistry) Register(invalidator appport.FaviconInvalidator) {
37+ if r == nil || invalidator == nil {
38+ return
39+ }
40+ r.mu.Lock()
41+ defer r.mu.Unlock()
42+ r.invalidators = append(r.invalidators, invalidator)
43+}
44+
45+func (r *faviconInvalidatorRegistry) InvalidateAll(ctx context.Context, key favicon.Key) error {
46+ if r == nil {
47+ return nil
48+ }
49+ r.mu.RLock()
50+ fallback := r.fallback
51+ invalidators := append([]appport.FaviconInvalidator(nil), r.invalidators...)
52+ r.mu.RUnlock()
53+ // The fallback invalidator owns pre-registered invalidation sets and is
54+ // treated as fatal; dynamic invalidators below are best-effort and joined.
55+ if fallback != nil {
56+ if err := fallback.InvalidateAll(ctx, key); err != nil {
57+ return err
58+ }
59+ }
60+ var errs []error
61+ for _, invalidator := range invalidators {
62+ if err := invalidator.Invalidate(ctx, key); err != nil {
63+ errs = append(errs, err)
64+ }
65+ }
66+ return errors.Join(errs...)
67+}
68+
69+type FaviconUseCase struct {
70+ repo appport.FaviconRepository
71+ blobs appport.FaviconBlobStore
72+ fetcher appport.FaviconFetcher
73+ discoverer appport.FaviconDiscoverer
74+ converter appport.FaviconImageConverter
75+ scheduler appport.FaviconRefreshScheduler
76+ invalidators *faviconInvalidatorRegistry
77+ now func() time.Time
78+ ttl time.Duration
79+ background context.Context
80+}
81+
82+type FaviconDeps struct {
83+ Repository appport.FaviconRepository
84+ BlobStore appport.FaviconBlobStore
85+ Fetcher appport.FaviconFetcher
86+ Discoverer appport.FaviconDiscoverer
87+ Converter appport.FaviconImageConverter
88+ Scheduler appport.FaviconRefreshScheduler
89+ Invalidators appport.FaviconInvalidators
90+ Now func() time.Time
91+ TTL time.Duration
92+ Background context.Context
93+}
94+
95+func NewFaviconUseCase(deps FaviconDeps) *FaviconUseCase {
96+ if deps.Now == nil {
97+ deps.Now = time.Now
98+ }
99+ if deps.TTL == 0 {
100+ deps.TTL = favicon.DefaultTTL
101+ }
102+ if deps.Background == nil {
103+ deps.Background = context.Background()
104+ }
105+ registry := &faviconInvalidatorRegistry{}
106+ if deps.Invalidators != nil {
107+ registry.fallback = deps.Invalidators
108+ }
109+ return &FaviconUseCase{
110+ repo: deps.Repository,
111+ blobs: deps.BlobStore,
112+ fetcher: deps.Fetcher,
113+ discoverer: deps.Discoverer,
114+ converter: deps.Converter,
115+ scheduler: deps.Scheduler,
116+ invalidators: registry,
117+ now: deps.Now,
118+ ttl: deps.TTL,
119+ background: deps.Background,
120+ }
121+}
122+
123+func (uc *FaviconUseCase) RegisterFaviconInvalidator(invalidator appport.FaviconInvalidator) {
124+ if uc == nil || uc.invalidators == nil || invalidator == nil {
125+ return
126+ }
127+ uc.invalidators.Register(invalidator)
128+}
129+
130+func (uc *FaviconUseCase) Resolve(
131+ ctx context.Context,
132+ rawURLOrDomain string,
133+ size int,
134+ options ResolveOptions,
135+) (*appport.ResolvedFavicon, error) {
136+ keys := favicon.Candidates(rawURLOrDomain)
137+ if len(keys) == 0 {
138+ return nil, ErrFaviconMiss
139+ }
140+
141+ for _, key := range keys {
142+ if err := ctx.Err(); err != nil {
143+ return nil, err
144+ }
145+ meta, err := uc.repo.Get(ctx, key)
146+ if err != nil {
147+ return nil, err
148+ }
149+ if meta == nil {
150+ continue
151+ }
152+ bytes, contentType, err := uc.readForSize(ctx, key, size)
153+ if err != nil {
154+ if !errors.Is(err, ErrFaviconMiss) {
155+ return nil, err
156+ }
157+ continue
158+ }
159+ res := &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: meta}
160+ if favicon.ShouldRefresh(meta, uc.now(), uc.ttl) && options.ScheduleBackgroundRefresh {
161+ res.BackgroundRefreshScheduled = uc.scheduleRefresh(key, meta.PageURL)
162+ }
163+ return res, nil
164+ }
165+
166+ if options.AllowBlockingRefresh {
167+ if err := uc.refresh(ctx, rawURLOrDomain, true); err != nil {
168+ return nil, err
169+ }
170+ return uc.Resolve(ctx, rawURLOrDomain, size, ResolveOptions{Purpose: options.Purpose})
171+ }
172+ if options.ScheduleBackgroundRefresh {
173+ uc.scheduleRefresh(keys[0], rawURLOrDomain)
174+ }
175+ return nil, ErrFaviconMiss
176+}
177+
178+func (uc *FaviconUseCase) ResolveSystemviewIcon(ctx context.Context, rawDomain string, size int) (*appport.ResolvedFavicon, error) {
179+ if size != SystemviewIconSize {
180+ return nil, ErrFaviconMiss
181+ }
182+ return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview})
183+}
184+
185+func (uc *FaviconUseCase) Observe(
186+ ctx context.Context,
187+ pageURL, iconURL string,
188+ bytes []byte,
189+ source favicon.Source,
190+ contentType string,
191+) (*favicon.Metadata, error) {
192+ key, ok := favicon.CanonicalKey(pageURL)
193+ if !ok || len(bytes) == 0 {
194+ return nil, ErrFaviconMiss
195+ }
196+ old, err := uc.repo.Get(ctx, key)
197+ if err != nil {
198+ return nil, err
199+ }
200+ now := uc.now()
201+ if !favicon.HasContentChanged(old, bytes) {
202+ if err := uc.repo.UpdateLastChecked(ctx, key, old.ContentHash, now); err != nil {
203+ return nil, err
204+ }
205+ old.LastCheckedAt = now
206+ return old, nil
207+ }
208+
209+ var converted *appport.ConvertedFavicon
210+ if uc.converter != nil {
211+ var err error
212+ converted, err = uc.converter.Convert(ctx, bytes, contentType, []int{32})
213+ if err != nil {
214+ return nil, err
215+ }
216+ }
217+
218+ if err := uc.blobs.WriteOriginal(ctx, key, bytes, contentType); err != nil {
219+ return nil, err
220+ }
221+ if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
222+ return nil, err
223+ }
224+ if converted != nil {
225+ if len(converted.PNG) > 0 {
226+ if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
227+ return nil, err
228+ }
229+ }
230+ if b := converted.SizedPNG[32]; len(b) > 0 {
231+ if err := uc.blobs.WriteSizedPNG(ctx, key, 32, b); err != nil {
232+ return nil, err
233+ }
234+ }
235+ }
236+ meta := favicon.Metadata{
237+ Key: key,
238+ SourceURL: iconURL,
239+ PageURL: pageURL,
240+ Source: source,
241+ ContentHash: favicon.Hash(bytes),
242+ ContentType: contentType,
243+ UpdatedAt: now,
244+ LastCheckedAt: now,
245+ }
246+ if err := uc.repo.Upsert(ctx, meta); err != nil {
247+ return nil, err
248+ }
249+ if err := uc.invalidateCaches(ctx, key); err != nil {
250+ return nil, err
251+ }
252+ return &meta, nil
253+}
254+
255+func (uc *FaviconUseCase) RefreshIfStale(ctx context.Context, pageURL string) error {
256+ return uc.refresh(ctx, pageURL, false)
257+}
258+
259+func (uc *FaviconUseCase) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string) error {
260+ if uc.fetcher == nil {
261+ return ErrFaviconMiss
262+ }
263+ var lastMiss error
264+ for _, iconURL := range iconURLs {
265+ if err := ctx.Err(); err != nil {
266+ return err
267+ }
268+ fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL, IconURL: iconURL})
269+ if errors.Is(err, ErrFaviconMiss) {
270+ lastMiss = err
271+ continue
272+ }
273+ if err != nil {
274+ return err
275+ }
276+ _, err = uc.Observe(ctx, fetched.PageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
277+ if errors.Is(err, ErrFaviconMiss) {
278+ lastMiss = err
279+ continue
280+ }
281+ return err
282+ }
283+ if lastMiss != nil {
284+ return lastMiss
285+ }
286+ return uc.RefreshIfStale(ctx, pageURL)
287+}
288+
289+func (uc *FaviconUseCase) refresh(ctx context.Context, pageURL string, force bool) error {
290+ keys := favicon.Candidates(pageURL)
291+ if len(keys) == 0 {
292+ return ErrFaviconMiss
293+ }
294+ if !force {
295+ fresh, err := uc.anyCandidateFresh(ctx, keys)
296+ if err != nil {
297+ return err
298+ }
299+ if fresh {
300+ return nil
301+ }
302+ }
303+ return uc.fetchAndObserve(ctx, pageURL)
304+}
305+
306+func (uc *FaviconUseCase) refreshKey(ctx context.Context, key favicon.Key, pageURL string) error {
307+ meta, err := uc.repo.Get(ctx, key)
308+ if err != nil {
309+ return err
310+ }
311+ if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
312+ return nil
313+ }
314+ return uc.fetchAndObserve(ctx, pageURL)
315+}
316+
317+func (uc *FaviconUseCase) anyCandidateFresh(ctx context.Context, keys []favicon.Key) (bool, error) {
318+ for _, key := range keys {
319+ meta, err := uc.repo.Get(ctx, key)
320+ if err != nil {
321+ return false, err
322+ }
323+ if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
324+ return true, nil
325+ }
326+ }
327+ return false, nil
328+}
329+
330+func (uc *FaviconUseCase) fetchAndObserve(ctx context.Context, pageURL string) error {
331+ if uc.fetcher == nil {
332+ return ErrFaviconMiss
333+ }
334+ fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL})
335+ if err != nil {
336+ return err
337+ }
338+ _, err = uc.Observe(ctx, fetched.PageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
339+ return err
340+}
341+
342+func (uc *FaviconUseCase) EnsureSized(ctx context.Context, key favicon.Key, size int) error {
343+ if _, _, err := uc.blobs.ReadSizedPNG(ctx, key, size); err == nil {
344+ return nil
345+ } else if !errors.Is(err, ErrFaviconMiss) {
346+ return err
347+ }
348+ orig, ct, err := uc.blobs.ReadOriginal(ctx, key)
349+ if err != nil {
350+ return err
351+ }
352+ converted, err := uc.converter.Convert(ctx, orig, ct, []int{size})
353+ if err != nil {
354+ return err
355+ }
356+ if len(converted.PNG) > 0 {
357+ if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
358+ return err
359+ }
360+ }
361+ if b := converted.SizedPNG[size]; len(b) > 0 {
362+ return uc.blobs.WriteSizedPNG(ctx, key, size, b)
363+ }
364+ return ErrFaviconMiss
365+}
366+
367+func (uc *FaviconUseCase) Invalidate(ctx context.Context, key favicon.Key) error {
368+ if uc.blobs != nil {
369+ if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
370+ return err
371+ }
372+ }
373+ return uc.invalidateCaches(ctx, key)
374+}
375+
376+func (uc *FaviconUseCase) invalidateCaches(ctx context.Context, key favicon.Key) error {
377+ if uc.invalidators != nil {
378+ return uc.invalidators.InvalidateAll(ctx, key)
379+ }
380+ return nil
381+}
382+
383+func (uc *FaviconUseCase) readForSize(ctx context.Context, key favicon.Key, size int) ([]byte, string, error) {
384+ if size < 0 {
385+ return nil, "", fmt.Errorf("invalid favicon size: %d", size)
386+ }
387+ if size > 0 {
388+ if err := uc.EnsureSized(ctx, key, size); err != nil {
389+ return nil, "", err
390+ }
391+ return uc.blobs.ReadSizedPNG(ctx, key, size)
392+ }
393+ return uc.blobs.ReadPNG(ctx, key)
394+}
395+
396+func (uc *FaviconUseCase) scheduleRefresh(key favicon.Key, pageURL string) bool {
397+ if uc.scheduler == nil {
398+ return false
399+ }
400+ return uc.scheduler.Schedule(uc.background, key, func(ctx context.Context) { _ = uc.refreshKey(ctx, key, pageURL) })
401+}
A internal/application/usecase/favicon_test.go +499 -0
@@ -0,0 +1,499 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ appport "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/favicon"
11+)
12+
13+func TestFaviconResolveExactHit(t *testing.T) {
14+ fx := newFaviconFixture()
15+ fx.seed("docs.example.com", []byte("png32"), false)
16+ got, err := fx.uc.Resolve(context.Background(), "https://docs.example.com/a", 32, ResolveOptions{Purpose: ResolvePurposeUI})
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if got.Key != "docs.example.com" || string(got.Bytes) != "png32" {
21+ t.Fatalf("unexpected resolve: %#v %q", got, got.Bytes)
22+ }
23+}
24+
25+func TestFaviconResolveParentFallback(t *testing.T) {
26+ fx := newFaviconFixture()
27+ fx.seed("example.com", []byte("parent"), false)
28+ got, err := fx.uc.Resolve(context.Background(), "https://docs.example.com/a", 32, ResolveOptions{Purpose: ResolvePurposeUI})
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ if got.Key != "example.com" || string(got.Bytes) != "parent" {
33+ t.Fatalf("unexpected fallback: %#v", got)
34+ }
35+}
36+
37+func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) {
38+ fx := newFaviconFixture()
39+ fx.seed("example.com", []byte("old"), true)
40+ for i := 0; i < 2; i++ {
41+ got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{ScheduleBackgroundRefresh: true})
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ if !got.BackgroundRefreshScheduled && i == 0 {
46+ t.Fatal("first stale resolve did not schedule refresh")
47+ }
48+ }
49+ if fx.scheduler.schedules != 1 {
50+ t.Fatalf("schedules=%d", fx.scheduler.schedules)
51+ }
52+ if fx.scheduler.usedCallerContext {
53+ t.Fatal("scheduler used caller context")
54+ }
55+}
56+
57+func TestFaviconResolveStaleExactRefreshIgnoresFreshParent(t *testing.T) {
58+ fx := newFaviconFixture()
59+ fx.seed("docs.example.com", []byte("old-exact"), true)
60+ fx.seed("example.com", []byte("fresh-parent"), false)
61+ got, err := fx.uc.Resolve(
62+ context.Background(),
63+ "https://docs.example.com/page",
64+ 32,
65+ ResolveOptions{ScheduleBackgroundRefresh: true},
66+ )
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ if got.Key != "docs.example.com" || !got.BackgroundRefreshScheduled {
71+ t.Fatalf("unexpected resolve: %#v", got)
72+ }
73+ fx.scheduler.run("docs.example.com")
74+ if string(fx.blobs.original["docs.example.com"]) != "fetched" {
75+ t.Fatalf("exact key was not refreshed, originals=%v", fx.blobs.original)
76+ }
77+}
78+
79+func TestFaviconObserveSameHashUpdatesMetadataOnly(t *testing.T) {
80+ fx := newFaviconFixture()
81+ fx.seedOriginal([]byte("same"))
82+ if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://cdn.test/icon.png", []byte("same"), favicon.SourceEngine, "image/png"); err != nil {
83+ t.Fatal(err)
84+ }
85+ if fx.blobs.writeOriginals != 0 || fx.converter.calls != 0 || fx.invalidators.calls != 0 {
86+ t.Fatalf("rewrote/converted/invalidated same hash")
87+ }
88+ if fx.repo.byKey["example.com"].LastCheckedAt != fx.now {
89+ t.Fatal("last checked not updated")
90+ }
91+}
92+
93+func TestFaviconObserveChangedHashRegeneratesAndInvalidates(t *testing.T) {
94+ fx := newFaviconFixture()
95+ fx.seedOriginal([]byte("old"))
96+ if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://cdn.test/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); err != nil {
97+ t.Fatal(err)
98+ }
99+ if fx.blobs.writeOriginals != 1 || fx.converter.calls != 1 || fx.blobs.removeDerived != 1 || fx.invalidators.calls != 1 {
100+ t.Fatalf("writes=%d converts=%d remove=%d invalidates=%d", fx.blobs.writeOriginals, fx.converter.calls, fx.blobs.removeDerived, fx.invalidators.calls)
101+ }
102+ if string(fx.blobs.png["example.com"]) != "png:new" || string(fx.blobs.sized["example.com"][32]) != "png32:new" {
103+ t.Fatal("derived pngs not regenerated")
104+ }
105+ if fx.repo.byKey["example.com"].SourceURL != "https://cdn.test/icon.png" {
106+ t.Fatal("iconURL not stored as metadata")
107+ }
108+}
109+
110+func TestFaviconObserveWithoutConverterStoresOriginalOnly(t *testing.T) {
111+ fx := newFaviconFixture()
112+ fx.uc.converter = nil
113+ if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://example.com/icon.png", []byte("raw"), favicon.SourceEngine, "image/png"); err != nil {
114+ t.Fatal(err)
115+ }
116+ if string(fx.blobs.original["example.com"]) != "raw" {
117+ t.Fatalf("original not stored: %v", fx.blobs.original)
118+ }
119+ if len(fx.blobs.png) != 0 || len(fx.blobs.sized) != 0 {
120+ t.Fatalf("unexpected derivatives: png=%v sized=%v", fx.blobs.png, fx.blobs.sized)
121+ }
122+}
123+
124+func TestFaviconResolveMissingFetchesOnlyWhenBlockingAllowed(t *testing.T) {
125+ fx := newFaviconFixture()
126+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, ErrFaviconMiss) {
127+ t.Fatalf("default resolve err=%v", err)
128+ }
129+ if fx.fetcher.calls != 0 {
130+ t.Fatal("default resolve blocked on fetch")
131+ }
132+ got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true})
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ if got.Key != "example.com" || fx.fetcher.calls != 1 {
137+ t.Fatalf("blocking resolve failed: %#v calls=%d", got, fx.fetcher.calls)
138+ }
139+}
140+
141+func TestFaviconRefreshIfStaleUsesParentFallbackBeforeFetching(t *testing.T) {
142+ fx := newFaviconFixture()
143+ fx.seed("example.com", []byte("parent"), false)
144+ if err := fx.uc.RefreshIfStale(context.Background(), "https://docs.example.com/page"); err != nil {
145+ t.Fatal(err)
146+ }
147+ if fx.fetcher.calls != 0 {
148+ t.Fatalf("fresh parent fallback should not fetch, calls=%d", fx.fetcher.calls)
149+ }
150+}
151+
152+func TestFaviconUseCasePropagatesRepositoryErrors(t *testing.T) {
153+ fx := newFaviconFixture()
154+ fx.repo.err = errors.New("database unavailable")
155+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, fx.repo.err) {
156+ t.Fatalf("Resolve err=%v, want %v", err, fx.repo.err)
157+ }
158+ if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://cdn.example/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); !errors.Is(err, fx.repo.err) {
159+ t.Fatalf("Observe err=%v, want %v", err, fx.repo.err)
160+ }
161+ if err := fx.uc.RefreshIfStale(context.Background(), "https://example.com"); !errors.Is(err, fx.repo.err) {
162+ t.Fatalf("RefreshIfStale err=%v, want %v", err, fx.repo.err)
163+ }
164+}
165+
166+func TestFaviconResolvePropagatesBlobErrors(t *testing.T) {
167+ fx := newFaviconFixture()
168+ fx.seed("example.com", []byte("cached"), false)
169+ fx.blobs.err = errors.New("disk unavailable")
170+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, fx.blobs.err) {
171+ t.Fatalf("Resolve err=%v, want %v", err, fx.blobs.err)
172+ }
173+}
174+
175+func TestFaviconResolveBlockingRefreshesWhenMetadataFreshButBlobMissing(t *testing.T) {
176+ fx := newFaviconFixture()
177+ fx.repo.byKey["example.com"] = &favicon.Metadata{Key: "example.com", PageURL: "https://example.com", ContentHash: favicon.Hash([]byte("old")), LastCheckedAt: fx.now}
178+ got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true})
179+ if err != nil {
180+ t.Fatal(err)
181+ }
182+ if got.Key != "example.com" || fx.fetcher.calls != 1 || string(got.Bytes) != "png32:fetched" {
183+ t.Fatalf("unexpected resolve after forced refresh: got=%#v calls=%d bytes=%q", got, fx.fetcher.calls, got.Bytes)
184+ }
185+}
186+
187+func TestFaviconResolveBlockingPropagatesFetchErrors(t *testing.T) {
188+ fx := newFaviconFixture()
189+ fx.fetcher.err = errors.New("network unavailable")
190+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true}); !errors.Is(err, fx.fetcher.err) {
191+ t.Fatalf("Resolve err=%v, want %v", err, fx.fetcher.err)
192+ }
193+}
194+
195+func TestFaviconRefreshFromIconURLsUsesPageURLKey(t *testing.T) {
196+ fx := newFaviconFixture()
197+ if err := fx.uc.RefreshFromIconURLs(context.Background(), "https://docs.example.com/page", []string{"https://cdn.example.net/icon.png"}); err != nil {
198+ t.Fatal(err)
199+ }
200+ if _, ok := fx.repo.byKey["docs.example.com"]; !ok {
201+ t.Fatalf("page URL key was not stored; keys=%v", fx.repo.byKey)
202+ }
203+ if _, ok := fx.repo.byKey["cdn.example.net"]; ok {
204+ t.Fatal("icon URL host should not be used as cache key")
205+ }
206+}
207+
208+func TestFaviconObserveConversionErrorDoesNotMutateExistingAssets(t *testing.T) {
209+ fx := newFaviconFixture()
210+ fx.seedOriginal([]byte("old"))
211+ fx.blobs.png["example.com"] = []byte("old-png")
212+ fx.blobs.sized["example.com"] = map[int][]byte{32: []byte("old-32")}
213+ fx.converter.err = errors.New("unsupported image")
214+
215+ if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://example.com/favicon.ico", []byte("bad"), favicon.SourceEngine, "image/svg+xml"); !errors.Is(err, fx.converter.err) {
216+ t.Fatalf("Observe err=%v, want %v", err, fx.converter.err)
217+ }
218+ if string(fx.blobs.original["example.com"]) != "old" || string(fx.blobs.png["example.com"]) != "old-png" || string(fx.blobs.sized["example.com"][32]) != "old-32" {
219+ t.Fatalf("existing assets mutated on conversion error")
220+ }
221+ if fx.blobs.writeOriginals != 0 || fx.blobs.removeDerived != 0 || fx.invalidators.calls != 0 {
222+ t.Fatalf("unexpected mutation counters writes=%d remove=%d invalidates=%d", fx.blobs.writeOriginals, fx.blobs.removeDerived, fx.invalidators.calls)
223+ }
224+}
225+
226+func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) {
227+ fx := newFaviconFixture()
228+ fx.seedOriginal([]byte("old"))
229+ fx.blobs.readSizedErr = errors.New("sized store unavailable")
230+ if err := fx.uc.EnsureSized(context.Background(), "example.com", 32); !errors.Is(err, fx.blobs.readSizedErr) {
231+ t.Fatalf("EnsureSized err=%v, want %v", err, fx.blobs.readSizedErr)
232+ }
233+ if fx.converter.calls != 0 {
234+ t.Fatal("converter called after non-miss ReadSizedPNG error")
235+ }
236+}
237+
238+type faviconFixture struct {
239+ repo *fakeFaviconRepo
240+ blobs *fakeBlobStore
241+ converter *fakeConverter
242+ scheduler *fakeScheduler
243+ invalidators *fakeInvalidators
244+ fetcher *fakeFetcher
245+ uc *FaviconUseCase
246+ now time.Time
247+}
248+
249+type faviconTestContextKey string
250+
251+const faviconFixtureBackgroundKey faviconTestContextKey = "bg"
252+
253+func newFaviconFixture() *faviconFixture {
254+ now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
255+ fx := &faviconFixture{
256+ repo: newFakeRepo(),
257+ blobs: newFakeBlobStore(),
258+ converter: &fakeConverter{},
259+ scheduler: &fakeScheduler{seen: map[favicon.Key]bool{}},
260+ invalidators: &fakeInvalidators{},
261+ fetcher: &fakeFetcher{},
262+ now: now,
263+ }
264+ fx.uc = NewFaviconUseCase(FaviconDeps{
265+ Repository: fx.repo,
266+ BlobStore: fx.blobs,
267+ Converter: fx.converter,
268+ Scheduler: fx.scheduler,
269+ Invalidators: fx.invalidators,
270+ Fetcher: fx.fetcher,
271+ Now: func() time.Time { return fx.now },
272+ Background: context.WithValue(context.Background(), faviconFixtureBackgroundKey, true),
273+ })
274+ return fx
275+}
276+func (f *faviconFixture) seed(key string, sized []byte, stale bool) {
277+ checked := f.now
278+ if stale {
279+ checked = f.now.Add(-favicon.DefaultTTL - time.Hour)
280+ }
281+ f.repo.byKey[favicon.Key(key)] = &favicon.Metadata{Key: favicon.Key(key), PageURL: "https://" + key, ContentHash: favicon.Hash([]byte("orig")), LastCheckedAt: checked}
282+ f.blobs.sized[favicon.Key(key)] = map[int][]byte{32: sized}
283+}
284+func (f *faviconFixture) seedOriginal(b []byte) {
285+ const contentType = "image/png"
286+ key := favicon.Key("example.com")
287+ f.repo.byKey[key] = &favicon.Metadata{
288+ Key: key,
289+ PageURL: "https://example.com",
290+ ContentHash: favicon.Hash(b),
291+ ContentType: contentType,
292+ LastCheckedAt: f.now.Add(-time.Hour),
293+ }
294+ f.blobs.original[key] = b
295+ f.blobs.contentTypes[key] = contentType
296+}
297+
298+type fakeFaviconRepo struct {
299+ byKey map[favicon.Key]*favicon.Metadata
300+ err error
301+}
302+
303+func newFakeRepo() *fakeFaviconRepo {
304+ return &fakeFaviconRepo{byKey: map[favicon.Key]*favicon.Metadata{}}
305+}
306+func (r *fakeFaviconRepo) Get(_ context.Context, key favicon.Key) (*favicon.Metadata, error) {
307+ if r.err != nil {
308+ return nil, r.err
309+ }
310+ if m := r.byKey[key]; m != nil {
311+ cp := *m
312+ return &cp, nil
313+ }
314+ return nil, nil
315+}
316+func (r *fakeFaviconRepo) FindFirst(ctx context.Context, keys []favicon.Key) (*favicon.Metadata, error) {
317+ if r.err != nil {
318+ return nil, r.err
319+ }
320+ for _, k := range keys {
321+ if m, _ := r.Get(ctx, k); m != nil {
322+ return m, nil
323+ }
324+ }
325+ return nil, nil
326+}
327+func (r *fakeFaviconRepo) Upsert(_ context.Context, meta favicon.Metadata) error {
328+ if r.err != nil {
329+ return r.err
330+ }
331+ cp := meta
332+ r.byKey[meta.Key] = &cp
333+ return nil
334+}
335+func (r *fakeFaviconRepo) UpdateLastChecked(_ context.Context, key favicon.Key, hash string, checkedAt time.Time) error {
336+ if r.err != nil {
337+ return r.err
338+ }
339+ r.byKey[key].ContentHash = hash
340+ r.byKey[key].LastCheckedAt = checkedAt
341+ return nil
342+}
343+func (r *fakeFaviconRepo) Delete(_ context.Context, key favicon.Key) error {
344+ if r.err != nil {
345+ return r.err
346+ }
347+ delete(r.byKey, key)
348+ return nil
349+}
350+
351+type fakeBlobStore struct {
352+ original, png map[favicon.Key][]byte
353+ sized map[favicon.Key]map[int][]byte
354+ contentTypes map[favicon.Key]string
355+ writeOriginals, removeDerived int
356+ err error
357+ readSizedErr error
358+}
359+
360+func newFakeBlobStore() *fakeBlobStore {
361+ return &fakeBlobStore{original: map[favicon.Key][]byte{}, png: map[favicon.Key][]byte{}, sized: map[favicon.Key]map[int][]byte{}, contentTypes: map[favicon.Key]string{}}
362+}
363+func (b *fakeBlobStore) ReadOriginal(_ context.Context, key favicon.Key) ([]byte, string, error) {
364+ if b.err != nil {
365+ return nil, "", b.err
366+ }
367+ v := b.original[key]
368+ if v == nil {
369+ return nil, "", ErrFaviconMiss
370+ }
371+ return v, b.contentTypes[key], nil
372+}
373+func (b *fakeBlobStore) WriteOriginal(_ context.Context, key favicon.Key, data []byte, ct string) error {
374+ if b.err != nil {
375+ return b.err
376+ }
377+ b.writeOriginals++
378+ b.original[key] = data
379+ b.contentTypes[key] = ct
380+ return nil
381+}
382+func (b *fakeBlobStore) ReadPNG(_ context.Context, key favicon.Key) ([]byte, string, error) {
383+ if b.err != nil {
384+ return nil, "", b.err
385+ }
386+ v := b.png[key]
387+ if v == nil {
388+ return nil, "", ErrFaviconMiss
389+ }
390+ return v, "image/png", nil
391+}
392+func (b *fakeBlobStore) WritePNG(_ context.Context, key favicon.Key, data []byte) error {
393+ if b.err != nil {
394+ return b.err
395+ }
396+ b.png[key] = data
397+ return nil
398+}
399+func (b *fakeBlobStore) ReadSizedPNG(_ context.Context, key favicon.Key, size int) ([]byte, string, error) {
400+ if b.err != nil {
401+ return nil, "", b.err
402+ }
403+ if b.readSizedErr != nil {
404+ return nil, "", b.readSizedErr
405+ }
406+ if b.sized[key] == nil || b.sized[key][size] == nil {
407+ return nil, "", ErrFaviconMiss
408+ }
409+ return b.sized[key][size], "image/png", nil
410+}
411+func (b *fakeBlobStore) WriteSizedPNG(_ context.Context, key favicon.Key, size int, data []byte) error {
412+ if b.err != nil {
413+ return b.err
414+ }
415+ if b.sized[key] == nil {
416+ b.sized[key] = map[int][]byte{}
417+ }
418+ b.sized[key][size] = data
419+ return nil
420+}
421+func (b *fakeBlobStore) RemoveDerived(_ context.Context, key favicon.Key) error {
422+ if b.err != nil {
423+ return b.err
424+ }
425+ b.removeDerived++
426+ delete(b.png, key)
427+ delete(b.sized, key)
428+ return nil
429+}
430+
431+type fakeConverter struct {
432+ calls int
433+ err error
434+}
435+
436+func (c *fakeConverter) Convert(_ context.Context, original []byte, _ string, sizes []int) (*appport.ConvertedFavicon, error) {
437+ c.calls++
438+ if c.err != nil {
439+ return nil, c.err
440+ }
441+ out := &appport.ConvertedFavicon{PNG: append([]byte("png:"), original...), SizedPNG: map[int][]byte{}}
442+ for _, s := range sizes {
443+ out.SizedPNG[s] = append([]byte("png32:"), original...)
444+ }
445+ return out, nil
446+}
447+
448+type fakeScheduler struct {
449+ seen map[favicon.Key]bool
450+ works map[favicon.Key]func(context.Context)
451+ schedules int
452+ usedCallerContext bool
453+}
454+
455+func (s *fakeScheduler) Schedule(ctx context.Context, key favicon.Key, work func(context.Context)) bool {
456+ if ctx.Value(faviconFixtureBackgroundKey) != true {
457+ s.usedCallerContext = true
458+ }
459+ if s.seen[key] {
460+ return false
461+ }
462+ if s.works == nil {
463+ s.works = map[favicon.Key]func(context.Context){}
464+ }
465+ s.seen[key] = true
466+ s.works[key] = work
467+ s.schedules++
468+ return true
469+}
470+
471+func (s *fakeScheduler) run(key favicon.Key) {
472+ if work := s.works[key]; work != nil {
473+ work(context.Background())
474+ }
475+}
476+
477+type fakeInvalidators struct{ calls int }
478+
479+func (i *fakeInvalidators) InvalidateAll(_ context.Context, _ favicon.Key) error {
480+ i.calls++
481+ return nil
482+}
483+
484+type fakeFetcher struct {
485+ calls int
486+ err error
487+}
488+
489+func (f *fakeFetcher) Fetch(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
490+ f.calls++
491+ if f.err != nil {
492+ return nil, f.err
493+ }
494+ iconURL := req.IconURL
495+ if iconURL == "" {
496+ iconURL = "https://example.com/favicon.ico"
497+ }
498+ return &appport.FaviconFetchedIcon{PageURL: req.PageURL, IconURL: iconURL, Bytes: []byte("fetched"), Source: favicon.SourceDuckDuckGo, ContentType: "image/png"}, nil
499+}
A internal/domain/favicon/key.go +132 -0
@@ -0,0 +1,132 @@
1+package favicon
2+
3+import (
4+ "net"
5+ "net/url"
6+ "slices"
7+ "strings"
8+
9+ "golang.org/x/net/idna"
10+ "golang.org/x/net/publicsuffix"
11+)
12+
13+type Key string
14+
15+func CanonicalKey(raw string) (Key, bool) {
16+ candidates := Candidates(raw)
17+ if len(candidates) == 0 {
18+ return "", false
19+ }
20+ return candidates[0], true
21+}
22+
23+func Candidates(raw string) []Key {
24+ host, port, hasScheme, scheme, ok := parseHost(raw)
25+ if !ok {
26+ return nil
27+ }
28+
29+ host = normalizeHost(host)
30+ if host == "" {
31+ return nil
32+ }
33+
34+ if isExactHost(host) {
35+ if port != "" && (!hasScheme || !isDefaultPort(scheme, port)) {
36+ return []Key{Key(net.JoinHostPort(host, port))}
37+ }
38+ return []Key{Key(host)}
39+ }
40+
41+ ascii, err := idna.Lookup.ToASCII(host)
42+ if err != nil {
43+ return nil
44+ }
45+ host = strings.ToLower(strings.TrimSuffix(ascii, "."))
46+ host = stripWWW(host)
47+
48+ if port != "" {
49+ if !hasScheme || !isDefaultPort(scheme, port) {
50+ return []Key{Key(net.JoinHostPort(host, port))}
51+ }
52+ }
53+
54+ labels := strings.Split(host, ".")
55+ if slices.Contains(labels, "") {
56+ return nil
57+ }
58+
59+ registrable, err := publicsuffix.EffectiveTLDPlusOne(host)
60+ if err != nil {
61+ return []Key{Key(host)}
62+ }
63+
64+ var out []Key
65+ for i := range len(labels) {
66+ candidate := strings.Join(labels[i:], ".")
67+ out = append(out, Key(candidate))
68+ if candidate == registrable {
69+ break
70+ }
71+ }
72+ return out
73+}
74+
75+func parseHost(raw string) (host, port string, hasScheme bool, scheme string, ok bool) {
76+ raw = strings.TrimSpace(raw)
77+ if raw == "" {
78+ return "", "", false, "", false
79+ }
80+
81+ if strings.Contains(raw, "://") {
82+ u, err := url.Parse(raw)
83+ if err != nil || u.Scheme == "" {
84+ return "", "", false, "", false
85+ }
86+ scheme = strings.ToLower(u.Scheme)
87+ if scheme != "http" && scheme != "https" {
88+ return "", "", true, scheme, false
89+ }
90+ host = u.Hostname()
91+ port = u.Port()
92+ return host, port, true, scheme, host != ""
93+ }
94+ if strings.HasPrefix(strings.ToLower(raw), "about:") {
95+ return "", "", false, "", false
96+ }
97+
98+ hostport := raw
99+ if cut := strings.IndexAny(hostport, "/?#"); cut >= 0 {
100+ hostport = hostport[:cut]
101+ }
102+ hostport = strings.TrimSuffix(hostport, ".")
103+
104+ if h, p, err := net.SplitHostPort(hostport); err == nil {
105+ return h, p, false, "", h != ""
106+ }
107+ if strings.Count(hostport, ":") == 1 {
108+ if h, p, ok := strings.Cut(hostport, ":"); ok && h != "" && p != "" {
109+ return h, p, false, "", true
110+ }
111+ }
112+ return hostport, "", false, "", hostport != ""
113+}
114+
115+func normalizeHost(host string) string {
116+ host = strings.TrimSpace(host)
117+ host = strings.Trim(host, "[]")
118+ host = strings.TrimSuffix(host, ".")
119+ return strings.ToLower(host)
120+}
121+
122+func stripWWW(host string) string {
123+ return strings.TrimPrefix(host, "www.")
124+}
125+
126+func isDefaultPort(scheme, port string) bool {
127+ return (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
128+}
129+
130+func isExactHost(host string) bool {
131+ return host == "localhost" || net.ParseIP(host) != nil
132+}
A internal/domain/favicon/key_test.go +57 -0
@@ -0,0 +1,57 @@
1+package favicon
2+
3+import "testing"
4+
5+func TestKeyCandidates(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ raw string
9+ want []Key
10+ }{
11+ {name: "walks public host to registrable domain", raw: "https://app.docs.example.com/path", want: []Key{"app.docs.example.com", "docs.example.com", "example.com"}},
12+ {name: "strips www", raw: "https://www.example.com", want: []Key{"example.com"}},
13+ {name: "localhost with port is exact", raw: "http://localhost:1455/path", want: []Key{"localhost:1455"}},
14+ {name: "ip with port is exact", raw: "http://127.0.0.1:8080/path", want: []Key{"127.0.0.1:8080"}},
15+ {name: "ipv6 with port is exact", raw: "http://[::1]:8080/path", want: []Key{"[::1]:8080"}},
16+ {name: "uses public suffix for co uk", raw: "https://news.bbc.co.uk/story", want: []Key{"news.bbc.co.uk", "bbc.co.uk"}},
17+
18+ {name: "drops default https port", raw: "https://example.com:443/path", want: []Key{"example.com"}},
19+ {name: "non default url port is exact", raw: "https://example.com:8443/path", want: []Key{"example.com:8443"}},
20+ {name: "host-like string lowercases and trims trailing dot", raw: "Docs.Example.COM.", want: []Key{"docs.example.com", "example.com"}},
21+ {name: "strips www before preserving non default port", raw: "https://www.example.com:8443", want: []Key{"example.com:8443"}},
22+ {name: "host-like string with port preserves port exact", raw: "example.com:443", want: []Key{"example.com:443"}},
23+ {name: "unicode host normalizes to punycode", raw: "https://bücher.example/path", want: []Key{"xn--bcher-kva.example"}},
24+ {name: "punycode equivalent matches unicode", raw: "https://xn--bcher-kva.example/path", want: []Key{"xn--bcher-kva.example"}},
25+ {name: "empty input has no candidates", raw: "", want: nil},
26+ {name: "about url has no candidates", raw: "about:blank", want: nil},
27+ {name: "unsupported scheme has no candidates", raw: "file:///tmp/icon.html", want: nil},
28+ }
29+
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ got := Candidates(tt.raw)
33+ if len(got) != len(tt.want) {
34+ t.Fatalf("Candidates(%q) len = %d (%v), want %d (%v)", tt.raw, len(got), got, len(tt.want), tt.want)
35+ }
36+ for i := range got {
37+ if got[i] != tt.want[i] {
38+ t.Fatalf("Candidates(%q)[%d] = %q, want %q (all got %v)", tt.raw, i, got[i], tt.want[i], got)
39+ }
40+ }
41+ })
42+ }
43+}
44+
45+func TestCanonicalKey(t *testing.T) {
46+ key, ok := CanonicalKey("https://www.Example.COM.:443/path")
47+ if !ok {
48+ t.Fatal("CanonicalKey returned false")
49+ }
50+ if key != "example.com" {
51+ t.Fatalf("CanonicalKey = %q, want example.com", key)
52+ }
53+
54+ if key, ok := CanonicalKey("about:blank"); ok || key != "" {
55+ t.Fatalf("CanonicalKey(about:blank) = %q, %v; want empty false", key, ok)
56+ }
57+}
A internal/domain/favicon/metadata.go +24 -0
@@ -0,0 +1,24 @@
1+package favicon
2+
3+import "time"
4+
5+type Source string
6+
7+const (
8+ SourceEngine Source = "engine"
9+ SourcePageDiscovery Source = "page_discovery"
10+ SourceDuckDuckGo Source = "duckduckgo"
11+ SourceInternal Source = "internal"
12+ SourceManual Source = "manual"
13+)
14+
15+type Metadata struct {
16+ Key Key
17+ SourceURL string
18+ PageURL string
19+ Source Source
20+ ContentHash string
21+ ContentType string
22+ UpdatedAt time.Time
23+ LastCheckedAt time.Time
24+}
A internal/domain/favicon/policy.go +31 -0
@@ -0,0 +1,31 @@
1+package favicon
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "time"
7+)
8+
9+const DefaultTTL = 30 * 24 * time.Hour
10+
11+func Hash(data []byte) string {
12+ sum := sha256.Sum256(data)
13+ return hex.EncodeToString(sum[:])
14+}
15+
16+func ShouldRefresh(meta *Metadata, now time.Time, ttl time.Duration) bool {
17+ if meta == nil {
18+ return true
19+ }
20+ if ttl <= 0 {
21+ return true
22+ }
23+ return !meta.LastCheckedAt.Add(ttl).After(now)
24+}
25+
26+func HasContentChanged(meta *Metadata, data []byte) bool {
27+ if meta == nil {
28+ return true
29+ }
30+ return meta.ContentHash != Hash(data)
31+}
A internal/domain/favicon/policy_test.go +42 -0
@@ -0,0 +1,42 @@
1+package favicon
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestShouldRefresh(t *testing.T) {
9+ now := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
10+ ttl := 30 * 24 * time.Hour
11+
12+ if !ShouldRefresh(nil, now, ttl) {
13+ t.Fatal("missing metadata should refresh")
14+ }
15+
16+ fresh := &Metadata{LastCheckedAt: now.Add(-ttl + time.Minute)}
17+ if ShouldRefresh(fresh, now, ttl) {
18+ t.Fatal("fresh metadata should not refresh")
19+ }
20+
21+ stale := &Metadata{LastCheckedAt: now.Add(-ttl - time.Minute)}
22+ if !ShouldRefresh(stale, now, ttl) {
23+ t.Fatal("stale metadata should refresh")
24+ }
25+}
26+
27+func TestContentHashChanged(t *testing.T) {
28+ data := []byte("favicon bytes")
29+ meta := &Metadata{ContentHash: Hash(data)}
30+
31+ if HasContentChanged(meta, data) {
32+ t.Fatal("same bytes should not be changed")
33+ }
34+
35+ if !HasContentChanged(meta, []byte("different bytes")) {
36+ t.Fatal("different bytes should be changed")
37+ }
38+
39+ if !HasContentChanged(nil, data) {
40+ t.Fatal("missing metadata should be treated as changed")
41+ }
42+}
A internal/infrastructure/cef/favicon_discovery.go +141 -0
@@ -0,0 +1,141 @@
1+package cef
2+
3+import (
4+ "net/url"
5+ "strings"
6+
7+ "golang.org/x/net/html"
8+)
9+
10+// DiscoverFaviconCandidates extracts ordered favicon URL candidates from page
11+// metadata and appends the origin /favicon.ico fallback. Only http(s) page and
12+// icon URLs are returned; duplicates are removed while preserving order.
13+func DiscoverFaviconCandidates(pageURL, htmlSource string) []string {
14+ candidates := make([]string, 0, 4)
15+ for _, href := range parseFaviconHrefs(htmlSource) {
16+ if resolved, ok := resolveFaviconURL(pageURL, href); ok {
17+ candidates = append(candidates, resolved)
18+ }
19+ }
20+ if fallback, ok := originFaviconURL(pageURL); ok {
21+ candidates = append(candidates, fallback)
22+ }
23+ if len(candidates) == 0 {
24+ return nil
25+ }
26+ return dedupeStrings(candidates)
27+}
28+
29+func resolveFaviconCandidates(pageURL string, rawIconURLs []string) []string {
30+ candidates := make([]string, 0, len(rawIconURLs))
31+ for _, raw := range rawIconURLs {
32+ if resolved, ok := resolveFaviconURL(pageURL, raw); ok {
33+ candidates = append(candidates, resolved)
34+ }
35+ }
36+ return dedupeStrings(candidates)
37+}
38+
39+func fallbackFaviconCandidates(pageURL string) []string {
40+ fallback, ok := originFaviconURL(pageURL)
41+ if !ok {
42+ return nil
43+ }
44+ return []string{fallback}
45+}
46+
47+func parseFaviconHrefs(htmlSource string) []string {
48+ z := html.NewTokenizer(strings.NewReader(htmlSource))
49+ var hrefs []string
50+ for {
51+ switch z.Next() {
52+ case html.ErrorToken:
53+ return hrefs
54+ case html.StartTagToken, html.SelfClosingTagToken:
55+ tn, hasAttr := z.TagName()
56+ if !hasAttr || !strings.EqualFold(string(tn), "link") {
57+ continue
58+ }
59+ var rel, href string
60+ for {
61+ key, val, more := z.TagAttr()
62+ switch strings.ToLower(string(key)) {
63+ case "rel":
64+ rel = string(val)
65+ case "href":
66+ href = string(val)
67+ }
68+ if !more {
69+ break
70+ }
71+ }
72+ if href != "" && faviconRel(rel) {
73+ hrefs = append(hrefs, href)
74+ }
75+ }
76+ }
77+}
78+
79+func faviconRel(rel string) bool {
80+ fields := strings.Fields(strings.ToLower(rel))
81+ for _, field := range fields {
82+ if field == "icon" || field == "apple-touch-icon" || field == "apple-touch-icon-precomposed" {
83+ return true
84+ }
85+ }
86+ return false
87+}
88+
89+func resolveFaviconURL(pageURL, rawIconURL string) (string, bool) {
90+ icon, err := url.Parse(strings.TrimSpace(rawIconURL))
91+ if err != nil || rawIconURL == "" {
92+ return "", false
93+ }
94+ if icon.IsAbs() {
95+ if !isHTTPScheme(icon.Scheme) || icon.Host == "" {
96+ return "", false
97+ }
98+ return icon.String(), true
99+ }
100+ base, err := url.Parse(pageURL)
101+ if err != nil || !isHTTPScheme(base.Scheme) || base.Host == "" {
102+ return "", false
103+ }
104+ resolved := base.ResolveReference(icon)
105+ if !isHTTPScheme(resolved.Scheme) || resolved.Host == "" {
106+ return "", false
107+ }
108+ return resolved.String(), true
109+}
110+
111+func originFaviconURL(pageURL string) (string, bool) {
112+ u, err := url.Parse(pageURL)
113+ if err != nil || !isHTTPScheme(u.Scheme) || u.Host == "" {
114+ return "", false
115+ }
116+ u.Path = "/favicon.ico"
117+ u.RawPath = ""
118+ u.RawQuery = ""
119+ u.Fragment = ""
120+ return u.String(), true
121+}
122+
123+func isHTTPScheme(scheme string) bool {
124+ return scheme == "http" || scheme == "https"
125+}
126+
127+func dedupeStrings(values []string) []string {
128+ seen := make(map[string]struct{}, len(values))
129+ out := make([]string, 0, len(values))
130+ for _, value := range values {
131+ if value == "" {
132+ continue
133+ }
134+ if _, ok := seen[value]; ok {
135+ continue
136+ }
137+ seen[value] = struct{}{}
138+ out = append(out, value)
139+ }
140+ return out
141+}
A internal/infrastructure/cef/favicon_discovery_test.go +103 -0
@@ -0,0 +1,103 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ purecef "github.com/bnema/purego-cef/cef"
9+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestFaviconDiscoveryResolvesLinksAndFallback(t *testing.T) {
15+ pageURL := "https://example.com/docs/page.html"
16+ html := `<html><head>
17+ <link rel="stylesheet" href="/style.css">
18+ <link rel="icon" href="/favicon.png">
19+ <link rel="shortcut icon" href="icons/shortcut.ico">
20+ <link rel="apple-touch-icon" href="https://cdn.example.com/apple.png">
21+ <link rel="icon" href="javascript:alert(1)">
22+ </head></html>`
23+
24+ got := DiscoverFaviconCandidates(pageURL, html)
25+ require.Equal(t, []string{
26+ "https://example.com/favicon.png",
27+ "https://example.com/docs/icons/shortcut.ico",
28+ "https://cdn.example.com/apple.png",
29+ "https://example.com/favicon.ico",
30+ }, got)
31+}
32+
33+func TestFaviconDiscoveryFallbackOnly(t *testing.T) {
34+ require.Equal(t, []string{"https://example.com/favicon.ico"}, DiscoverFaviconCandidates("https://example.com/path", "<html></html>"))
35+ require.Nil(t, DiscoverFaviconCandidates("about:blank", "<html></html>"))
36+}
37+
38+func TestFaviconDiscoveryKeepsAbsoluteIconsForOpaquePageURL(t *testing.T) {
39+ got := DiscoverFaviconCandidates("about:blank", `<link rel="icon" href="https://cdn.example.com/icon.png">`)
40+ require.Equal(t, []string{"https://cdn.example.com/icon.png"}, got)
41+}
42+
43+func TestFaviconDiscoveryIgnoresUnsafeIconURLs(t *testing.T) {
44+ got := DiscoverFaviconCandidates("https://example.com/path", `<link rel="icon" href="data:image/png;base64,AAAA"><link rel="icon" href="//cdn.example.com/icon.png">`)
45+ require.Equal(t, []string{"https://cdn.example.com/icon.png", "https://example.com/favicon.ico"}, got)
46+}
47+
48+func TestCEFOnFaviconUrlchangeForwardsOrderedCandidates(t *testing.T) {
49+ prevDecode := decodeCEFStringList
50+ decodeCEFStringList = func(list purecef.StringList) []string {
51+ require.Equal(t, purecef.StringList(42), list)
52+ return []string{"/favicon.png", "https://cdn.example.com/icon.ico", "javascript:alert(1)"}
53+ }
54+ defer func() { decodeCEFStringList = prevDecode }()
55+
56+ var page string
57+ var icons []string
58+ wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{
59+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
60+ }}
61+
62+ (&handlerSet{wv: wv}).OnFaviconUrlchange(nil, purecef.StringList(42))
63+
64+ require.Equal(t, "https://example.com/docs/page", page)
65+ require.Equal(t, []string{"https://example.com/favicon.png", "https://cdn.example.com/icon.ico"}, icons)
66+}
67+
68+func TestCEFLoadingFinishedDiscoversMetadataBeforeFallback(t *testing.T) {
69+ prevNewStringVisitor := cefNewStringVisitor
70+ cefNewStringVisitor = func(visitor purecef.StringVisitor) purecef.StringVisitor { return visitor }
71+ defer func() { cefNewStringVisitor = prevNewStringVisitor }()
72+
73+ browser := cefmocks.NewMockBrowser(t)
74+ frame := cefmocks.NewMockFrame(t)
75+ browser.EXPECT().GetMainFrame().Return(frame)
76+ frame.EXPECT().GetSource(mock.Anything).Run(func(visitor purecef.StringVisitor) {
77+ visitor.Visit(`<html><head><link rel="icon" href="/meta.png"></head></html>`)
78+ }).Return()
79+
80+ var page string
81+ var icons []string
82+ wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{
83+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
84+ }}
85+
86+ (&handlerSet{wv: wv}).OnLoadingStateChange(browser, 0, 1, 0)
87+
88+ require.Equal(t, "https://example.com/docs/page", page)
89+ require.Equal(t, []string{"https://example.com/meta.png", "https://example.com/favicon.ico"}, icons)
90+}
91+
92+func TestCEFLoadingFinishedForwardsFallbackCandidate(t *testing.T) {
93+ var page string
94+ var icons []string
95+ wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{
96+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
97+ }}
98+
99+ (&handlerSet{wv: wv}).OnLoadingStateChange(nil, 0, 1, 0)
100+
101+ require.Equal(t, "https://example.com/docs/page", page)
102+ require.Equal(t, []string{"https://example.com/favicon.ico"}, icons)
103+}
M internal/infrastructure/cef/favicon_systemviews.go +25 -7
@@ -1,15 +1,33 @@
11 package cef
22
3-import "github.com/bnema/dumber/internal/application/port"
3+import (
4+ "context"
45
5-var _ port.SystemviewFaviconServiceSetter = (*Engine)(nil)
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/domain/favicon"
8+)
69
7-// SetSystemviewFaviconService exposes the local favicon cache to internal
8-// systemview pages. The scheme handler only serves cached PNGs; it does not
9-// fetch remote favicons on behalf of History rendering.
10-func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) {
10+var _ port.SystemviewFaviconResolverSetter = (*Engine)(nil)
11+var _ port.FaviconInvalidator = (*Engine)(nil)
12+
13+// SetSystemviewFaviconResolver exposes the application favicon resolver to
14+// internal systemview pages without giving engine code disk-service access.
15+func (e *Engine) SetSystemviewFaviconResolver(resolver port.FaviconSystemviewResolver) {
1116 if e == nil || e.schemeHandler == nil {
1217 return
1318 }
14- e.schemeHandler.setFaviconService(service)
19+ e.schemeHandler.setFaviconResolver(resolver)
20+}
21+
22+func (e *Engine) Invalidate(ctx context.Context, key favicon.Key) error {
23+ if e == nil || e.schemeHandler == nil {
24+ return nil
25+ }
26+ return e.schemeHandler.invalidateFavicon(ctx, key)
27+}
28+
29+func (h *dumbSchemeHandler) invalidateFavicon(_ context.Context, _ favicon.Key) error {
30+ // CEF systemview favicon requests resolve through the application usecase on
31+ // each request, so this adapter does not own a favicon response cache.
32+ return nil
1533 }
M internal/infrastructure/cef/scheme_handler.go +29 -113
@@ -12,7 +12,6 @@ import (
1212 "io/fs"
1313 "net/http"
1414 "net/url"
15- "os"
1615 "path"
1716 "strconv"
1817 "strings"
@@ -23,7 +22,6 @@ import (
2322 purecef "github.com/bnema/purego-cef/cef"
2423
2524 "github.com/bnema/dumber/internal/application/port"
26- domainurl "github.com/bnema/dumber/internal/domain/url"
2725 "github.com/bnema/dumber/internal/infrastructure/webutil"
2826 "github.com/bnema/dumber/internal/logging"
2927 "github.com/rs/zerolog"
@@ -56,7 +54,7 @@ var cefNewResourceHandler = purecef.NewResourceHandler
5654 type dumbSchemeHandler struct {
5755 ctx context.Context
5856 messageRouter *MessageRouter
59- faviconService port.FaviconService
57+ faviconResolver port.FaviconSystemviewResolver
6058 assets embed.FS
6159 assetsSet bool
6260 assetDir string
@@ -121,10 +119,10 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
121119 h.logger.Debug().Msg("assets filesystem configured")
122120 }
123121
124-func (h *dumbSchemeHandler) setFaviconService(service port.FaviconService) {
122+func (h *dumbSchemeHandler) setFaviconResolver(resolver port.FaviconSystemviewResolver) {
125123 h.mu.Lock()
126124 defer h.mu.Unlock()
127- h.faviconService = service
125+ h.faviconResolver = resolver
128126 }
129127
130128 // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread
@@ -311,9 +309,9 @@ func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.Re
311309 }
312310
313311 h.mu.RLock()
314- service := h.faviconService
312+ resolver := h.faviconResolver
315313 h.mu.RUnlock()
316- if service == nil {
314+ if resolver == nil {
317315 return h.newPrivateAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "favicon unavailable"})
318316 }
319317
@@ -321,7 +319,7 @@ func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.Re
321319 if err != nil {
322320 return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})
323321 }
324- domain := domainurl.CanonicalDomain(parsed.Query().Get("domain"))
322+ domain := strings.TrimSpace(parsed.Query().Get("domain"))
325323 if domain == "" {
326324 return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing domain"})
327325 }
@@ -334,7 +332,7 @@ func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.Re
334332 size = parsedSize
335333 }
336334
337- return newFaviconResourceHandler(service, domain, size)
335+ return newFaviconResourceHandler(h.ctx, resolver, domain, size)
338336 }
339337
340338 func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler {
@@ -833,91 +831,25 @@ func (h *dumbSchemeHandler) newPrivateAPIJSONResourceHandler(status int, v any)
833831 return h.newPrivateAPIRawResourceHandler(status, "application/json", data)
834832 }
835833
836-func newFaviconResourceHandler(service port.FaviconService, domain string, size int) purecef.ResourceHandler {
834+func newFaviconResourceHandler(
835+ ctx context.Context,
836+ resolver port.FaviconSystemviewResolver,
837+ domain string,
838+ size int,
839+) purecef.ResourceHandler {
837840 return cefNewResourceHandler(&faviconResourceHandler{
838- service: service,
839- domain: domain,
840- size: size,
841- done: make(chan struct{}),
842- headers: map[string]string{"Cache-Control": "no-store"},
841+ ctx: ctx,
842+ resolver: resolver,
843+ domain: domain,
844+ size: size,
845+ done: make(chan struct{}),
846+ headers: map[string]string{"Cache-Control": "no-store"},
843847 })
844848 }
845849
846-const maxFaviconMemoryCacheEntries = 256
847-
848-var systemviewFaviconCache = newFaviconMemoryCache(maxFaviconMemoryCacheEntries)
849-
850-type faviconCacheEntry struct {
851- statusCode int
852- contentType string
853- data []byte
854-}
855-
856-type faviconMemoryCache struct {
857- mu sync.Mutex
858- max int
859- entries map[string]faviconCacheEntry
860- order []string
861-}
862-
863-func newFaviconMemoryCache(maxEntries int) *faviconMemoryCache {
864- return &faviconMemoryCache{
865- max: maxEntries,
866- entries: make(map[string]faviconCacheEntry),
867- order: make([]string, 0, maxEntries),
868- }
869-}
870-
871-func faviconCacheKey(domain string, size int) string {
872- return strings.ToLower(strings.TrimSpace(domain)) + "\x00" + strconv.Itoa(size)
873-}
874-
875-func (c *faviconMemoryCache) get(key string) (faviconCacheEntry, bool) {
876- if c == nil || key == "" {
877- return faviconCacheEntry{}, false
878- }
879- c.mu.Lock()
880- defer c.mu.Unlock()
881- entry, ok := c.entries[key]
882- if !ok {
883- return faviconCacheEntry{}, false
884- }
885- c.moveToBackLocked(key)
886- return entry, true
887-}
888-
889-func (c *faviconMemoryCache) put(key string, entry faviconCacheEntry) {
890- if c == nil || key == "" || c.max <= 0 {
891- return
892- }
893- c.mu.Lock()
894- defer c.mu.Unlock()
895- if _, ok := c.entries[key]; !ok {
896- c.order = append(c.order, key)
897- } else {
898- c.moveToBackLocked(key)
899- }
900- c.entries[key] = entry
901- for len(c.order) > c.max {
902- oldest := c.order[0]
903- c.order = c.order[1:]
904- delete(c.entries, oldest)
905- }
906-}
907-
908-func (c *faviconMemoryCache) moveToBackLocked(key string) {
909- for i, candidate := range c.order {
910- if candidate != key {
911- continue
912- }
913- copy(c.order[i:], c.order[i+1:])
914- c.order[len(c.order)-1] = key
915- return
916- }
917-}
918-
919850 type faviconResourceHandler struct {
920- service port.FaviconService
851+ ctx context.Context
852+ resolver port.FaviconSystemviewResolver
921853 domain string
922854 size int
923855 headers map[string]string
@@ -934,35 +866,19 @@ func (rh *faviconResourceHandler) load() {
934866 rh.statusCode = http.StatusNotFound
935867 rh.contentType = "application/json"
936868 rh.data = []byte(`{"error":"favicon not cached"}`)
937- if rh.service == nil {
938- return
939- }
940- cacheKey := faviconCacheKey(rh.domain, rh.size)
941- if entry, ok := systemviewFaviconCache.get(cacheKey); ok {
942- rh.statusCode = entry.statusCode
943- rh.contentType = entry.contentType
944- rh.data = entry.data
945- return
946- }
947- if !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) {
869+ if rh.resolver == nil {
948870 return
949871 }
950- diskPath := rh.service.DiskPathPNGSized(rh.domain, rh.size)
951- if diskPath == "" {
952- return
953- }
954- data, err := os.ReadFile(diskPath)
955- if err != nil || len(data) == 0 {
872+ resolved, err := rh.resolver.ResolveSystemviewIcon(rh.ctx, rh.domain, rh.size)
873+ if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
956874 return
957875 }
958876 rh.statusCode = http.StatusOK
959- rh.contentType = "image/png"
960- rh.data = data
961- systemviewFaviconCache.put(cacheKey, faviconCacheEntry{
962- statusCode: rh.statusCode,
963- contentType: rh.contentType,
964- data: data,
965- })
877+ rh.contentType = resolved.ContentType
878+ if rh.contentType == "" {
879+ rh.contentType = "image/png"
880+ }
881+ rh.data = resolved.Bytes
966882 }
967883
968884 func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {
M internal/infrastructure/cef/scheme_handler_test.go +5 -18
@@ -10,6 +10,8 @@ import (
1010 purecef "github.com/bnema/purego-cef/cef"
1111 cefmocks "github.com/bnema/purego-cef/cef/mocks"
1212 "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/dumber/internal/application/port"
1315 )
1416
1517 func expectAPIResponseHeaders(response *cefmocks.MockResponse) {
@@ -28,26 +30,11 @@ func expectPrivateAPIResponseHeaders(response *cefmocks.MockResponse) {
2830 response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once()
2931 }
3032
31-type panicFaviconService struct{}
33+type panicSystemviewFaviconResolver struct{}
3234
33-func (panicFaviconService) GetCached(context.Context, string) ([]byte, bool) {
34- panic("unexpected favicon lookup")
35-}
36-func (panicFaviconService) Get(context.Context, string) ([]byte, error) {
37- panic("unexpected favicon lookup")
38-}
39-func (panicFaviconService) DiskPathPNG(string) string { panic("unexpected favicon lookup") }
40-func (panicFaviconService) HasPNGOnDisk(string) bool { panic("unexpected favicon lookup") }
41-func (panicFaviconService) HasPNGSizedOnDisk(string, int) bool { panic("unexpected favicon lookup") }
42-func (panicFaviconService) DiskPathPNGSized(string, int) string { panic("unexpected favicon lookup") }
43-func (panicFaviconService) EnsureSizedPNG(context.Context, string, int) error {
44- panic("unexpected favicon lookup")
45-}
46-func (panicFaviconService) EnsureCacheDir() error { panic("unexpected favicon lookup") }
47-func (panicFaviconService) EnsureDiskCache(context.Context, string) {
35+func (panicSystemviewFaviconResolver) ResolveSystemviewIcon(context.Context, string, int) (*port.ResolvedFavicon, error) {
4836 panic("unexpected favicon lookup")
4937 }
50-func (panicFaviconService) Close() {}
5138
5239 // noopConfigPayload returns a builder that always returns an empty JSON object.
5340 func noopConfigPayload() func() ([]byte, error) {
@@ -310,7 +297,7 @@ func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T)
310297 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
311298
312299 h := newTestDumbSchemeHandler(t)
313- h.setFaviconService(panicFaviconService{})
300+ h.setFaviconResolver(panicSystemviewFaviconResolver{})
314301
315302 request := cefmocks.NewMockRequest(t)
316303 request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
M internal/infrastructure/cef/webview.go +10 -4
@@ -67,10 +67,11 @@ const (
6767 )
6868
6969 var (
70- cefNewTask = purecef.NewTask
71- cefPostTask = purecef.PostTask
72- cefPostDelayedTask = purecef.PostDelayedTask
73- cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) }
70+ cefNewTask = purecef.NewTask
71+ cefNewStringVisitor = purecef.NewStringVisitor
72+ cefPostTask = purecef.PostTask
73+ cefPostDelayedTask = purecef.PostDelayedTask
74+ cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) }
7475 )
7576
7677 // WebView implements port.WebView using a CEF off-screen browser rendered and
@@ -199,6 +200,11 @@ type WebView struct {
199200 // Last known hover URI for middle-click → new tab.
200201 lastHoverURI string
201202
203+ // Favicon source visitors are retained until CEF calls them back because
204+ // GetSource delivers asynchronously through a C callback pointer.
205+ faviconSourceVisitorsMu sync.Mutex
206+ faviconSourceVisitors []faviconSourceVisitorRetention
207+
202208 // Load diagnostics state (mutex-protected).
203209 loadDiagSeq uint64
204210 loadDiagStartedAt time.Time
M internal/infrastructure/cef/webview_handlers.go +110 -2
@@ -4,6 +4,8 @@ import (
44 "context"
55 "strings"
66 "sync"
7+ "sync/atomic"
8+ "time"
79
810 purecef "github.com/bnema/purego-cef/cef"
911
@@ -176,7 +178,25 @@ func (h *handlerSet) OnTitleChange(_ purecef.Browser, title string) {
176178 h.wv.updateTitle(title)
177179 }
178180
179-func (h *handlerSet) OnFaviconUrlchange(_ purecef.Browser, _ purecef.StringList) {}
181+func (h *handlerSet) OnFaviconUrlchange(_ purecef.Browser, iconURLs purecef.StringList) {
182+ if h == nil || h.wv == nil {
183+ return
184+ }
185+ h.wv.mu.RLock()
186+ pageURL := h.wv.uri
187+ cb := h.wv.callbacks
188+ h.wv.mu.RUnlock()
189+ if cb == nil || cb.OnFaviconURLChanged == nil {
190+ return
191+ }
192+ candidates := resolveFaviconCandidates(pageURL, decodeCEFStringList(iconURLs))
193+ if len(candidates) == 0 {
194+ return
195+ }
196+ h.wv.runOnGTK(func() {
197+ cb.OnFaviconURLChanged(pageURL, candidates)
198+ })
199+}
180200
181201 // OnFullscreenModeChange toggles the fullscreen atomic and fires callbacks.
182202 func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32) {
@@ -285,8 +305,23 @@ func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect)
285305 // LoadHandler (4 methods)
286306 // ===========================================================================
287307
308+type faviconSourceVisitorRetention struct {
309+ id uint64
310+ visitor purecef.StringVisitor
311+}
312+
313+type faviconSourceVisitor struct {
314+ visit func(string)
315+}
316+
317+func (v faviconSourceVisitor) Visit(source string) {
318+ if v.visit != nil {
319+ v.visit(source)
320+ }
321+}
322+
288323 // OnLoadingStateChange updates the loading/navigation state cache and fires callbacks.
289-func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) {
324+func (h *handlerSet) OnLoadingStateChange(browser purecef.Browser, isloading, cangoback, cangoforward int32) {
290325 loading := isloading != 0
291326 h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0)
292327 if h.wv != nil && h.wv.ctx != nil {
@@ -320,7 +355,11 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac
320355
321356 h.wv.mu.RLock()
322357 cb := h.wv.callbacks
358+ pageURL := h.wv.uri
323359 h.wv.mu.RUnlock()
360+ if !loading && cb != nil && cb.OnFaviconURLChanged != nil {
361+ h.emitDiscoveredFaviconCandidates(browser, pageURL, cb)
362+ }
324363 if cb != nil && cb.OnLoadChanged != nil {
325364 if loading {
326365 h.wv.runOnGTK(func() {
@@ -334,6 +373,75 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac
334373 }
335374 }
336375
376+var faviconSourceVisitorRetentionID atomic.Uint64
377+
378+func (wv *WebView) retainFaviconSourceVisitor(visitor purecef.StringVisitor) func() {
379+ if wv == nil || visitor == nil {
380+ return func() {}
381+ }
382+ id := faviconSourceVisitorRetentionID.Add(1)
383+ wv.faviconSourceVisitorsMu.Lock()
384+ wv.faviconSourceVisitors = append(wv.faviconSourceVisitors, faviconSourceVisitorRetention{id: id, visitor: visitor})
385+ wv.faviconSourceVisitorsMu.Unlock()
386+ return func() {
387+ wv.faviconSourceVisitorsMu.Lock()
388+ defer wv.faviconSourceVisitorsMu.Unlock()
389+ for i, candidate := range wv.faviconSourceVisitors {
390+ if candidate.id == id {
391+ wv.faviconSourceVisitors = append(wv.faviconSourceVisitors[:i], wv.faviconSourceVisitors[i+1:]...)
392+ return
393+ }
394+ }
395+ }
396+}
397+
398+func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pageURL string, cb *port.WebViewCallbacks) {
399+ if h == nil || h.wv == nil || cb == nil || cb.OnFaviconURLChanged == nil {
400+ return
401+ }
402+ fallback := fallbackFaviconCandidates(pageURL)
403+ emit := func(candidates []string) {
404+ if len(candidates) == 0 {
405+ return
406+ }
407+ h.wv.runOnGTK(func() {
408+ cb.OnFaviconURLChanged(pageURL, candidates)
409+ })
410+ }
411+ if browser == nil {
412+ emit(fallback)
413+ return
414+ }
415+ frame := browser.GetMainFrame()
416+ if frame == nil {
417+ emit(fallback)
418+ return
419+ }
420+ var releaseOnce sync.Once
421+ var release func()
422+ visitor := cefNewStringVisitor(faviconSourceVisitor{visit: func(source string) {
423+ releaseOnce.Do(func() {
424+ if release != nil {
425+ release()
426+ }
427+ })
428+ candidates := DiscoverFaviconCandidates(pageURL, source)
429+ if len(candidates) == 0 {
430+ candidates = fallback
431+ }
432+ emit(candidates)
433+ }})
434+ release = h.wv.retainFaviconSourceVisitor(visitor)
435+ time.AfterFunc(5*time.Second, func() {
436+ releaseOnce.Do(func() {
437+ if release != nil {
438+ release()
439+ }
440+ })
441+ })
442+ frame.GetSource(visitor)
443+}
444+
337445 // OnLoadStart fires LoadCommitted for main frame navigations.
338446 // In CEF this callback runs after navigation commit, so it is the closest
339447 // equivalent to WebKit's LoadCommitted event.
A internal/infrastructure/favicon/blob_store.go +167 -0
@@ -0,0 +1,167 @@
1+package favicon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+
11+ appport "github.com/bnema/dumber/internal/application/port"
12+ domainfavicon "github.com/bnema/dumber/internal/domain/favicon"
13+)
14+
15+const contentTypeSuffix = ".content-type"
16+
17+// BlobStore stores favicon originals and derived PNG files on disk.
18+type BlobStore struct {
19+ cache *Cache
20+}
21+
22+func NewBlobStore(diskDir string) *BlobStore { return &BlobStore{cache: NewCache(diskDir)} }
23+func NewBlobStoreFromCache(cache *Cache) *BlobStore { return &BlobStore{cache: cache} }
24+
25+func (s *BlobStore) ReadOriginal(ctx context.Context, key domainfavicon.Key) ([]byte, string, error) {
26+ _ = ctx
27+ path := s.cache.DiskPath(string(key))
28+ data, err := readNonEmpty(path)
29+ if err != nil {
30+ return nil, "", err
31+ }
32+ ct, err := os.ReadFile(path + contentTypeSuffix)
33+ if err != nil && !errors.Is(err, os.ErrNotExist) {
34+ return nil, "", err
35+ }
36+ return data, strings.TrimSpace(string(ct)), nil
37+}
38+
39+func (s *BlobStore) WriteOriginal(ctx context.Context, key domainfavicon.Key, data []byte, contentType string) error {
40+ _ = ctx
41+ path := s.cache.DiskPath(string(key))
42+ if err := atomicWrite(path, data); err != nil {
43+ return err
44+ }
45+ if contentType != "" {
46+ return atomicWrite(path+contentTypeSuffix, []byte(contentType))
47+ }
48+ _ = os.Remove(path + contentTypeSuffix)
49+ return nil
50+}
51+
52+func (s *BlobStore) ReadPNG(ctx context.Context, key domainfavicon.Key) ([]byte, string, error) {
53+ _ = ctx
54+ data, err := readNonEmpty(s.cache.DiskPathPNG(string(key)))
55+ if err != nil {
56+ return nil, "", err
57+ }
58+ return data, "image/png", nil
59+}
60+func (s *BlobStore) WritePNG(ctx context.Context, key domainfavicon.Key, data []byte) error {
61+ _ = ctx
62+ return atomicWrite(s.cache.DiskPathPNG(string(key)), data)
63+}
64+func (s *BlobStore) ReadSizedPNG(ctx context.Context, key domainfavicon.Key, size int) ([]byte, string, error) {
65+ _ = ctx
66+ data, err := readNonEmpty(s.cache.DiskPathPNGSized(string(key), size))
67+ if err != nil {
68+ return nil, "", err
69+ }
70+ return data, "image/png", nil
71+}
72+func (s *BlobStore) WriteSizedPNG(ctx context.Context, key domainfavicon.Key, size int, data []byte) error {
73+ _ = ctx
74+ return atomicWrite(s.cache.DiskPathPNGSized(string(key), size), data)
75+}
76+
77+func (s *BlobStore) RemoveDerived(ctx context.Context, key domainfavicon.Key) error {
78+ _ = ctx
79+ if s.cache == nil || s.cache.diskDir == "" {
80+ return nil
81+ }
82+ pngPath := s.cache.DiskPathPNG(string(key))
83+ paths := []string{pngPath}
84+ var errs []error
85+ basePrefix := strings.TrimSuffix(filepath.Base(pngPath), ".png") + "."
86+ pattern := filepath.Join(s.cache.diskDir, basePrefix+"*.png")
87+ matches, globErr := filepath.Glob(pattern)
88+ if globErr != nil {
89+ errs = append(errs, globErr)
90+ }
91+ for _, match := range matches {
92+ if isSizedPNGForKey(filepath.Base(match), basePrefix) {
93+ paths = append(paths, match)
94+ }
95+ }
96+ for _, p := range paths {
97+ if p != "" {
98+ if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
99+ errs = append(errs, err)
100+ }
101+ }
102+ }
103+ return errors.Join(errs...)
104+}
105+
106+func isSizedPNGForKey(baseName, basePrefix string) bool {
107+ if !strings.HasPrefix(baseName, basePrefix) || !strings.HasSuffix(baseName, ".png") {
108+ return false
109+ }
110+ sizePart := strings.TrimSuffix(strings.TrimPrefix(baseName, basePrefix), ".png")
111+ if sizePart == "" {
112+ return false
113+ }
114+ for _, r := range sizePart {
115+ if r < '0' || r > '9' {
116+ return false
117+ }
118+ }
119+ return true
120+}
121+
122+func readNonEmpty(path string) ([]byte, error) {
123+ if path == "" {
124+ return nil, appport.ErrFaviconMiss
125+ }
126+ data, err := os.ReadFile(path)
127+ if errors.Is(err, os.ErrNotExist) {
128+ return nil, appport.ErrFaviconMiss
129+ }
130+ if err != nil {
131+ return nil, err
132+ }
133+ if len(data) == 0 {
134+ return nil, appport.ErrFaviconMiss
135+ }
136+ return data, nil
137+}
138+
139+func atomicWrite(path string, data []byte) error {
140+ if path == "" || len(data) == 0 {
141+ return appport.ErrFaviconInvalidInput
142+ }
143+ if err := os.MkdirAll(filepath.Dir(path), diskCacheDirPerm); err != nil {
144+ return err
145+ }
146+ tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*.tmp")
147+ if err != nil {
148+ return err
149+ }
150+ tmpName := tmp.Name()
151+ defer func() { _ = os.Remove(tmpName) }()
152+ if _, err := tmp.Write(data); err != nil {
153+ _ = tmp.Close()
154+ return err
155+ }
156+ if err := tmp.Chmod(diskCacheFilePerm); err != nil {
157+ _ = tmp.Close()
158+ return err
159+ }
160+ if err := tmp.Close(); err != nil {
161+ return err
162+ }
163+ if err := os.Rename(tmpName, path); err != nil {
164+ return fmt.Errorf("atomic write %s: %w", path, err)
165+ }
166+ return nil
167+}
A internal/infrastructure/favicon/blob_store_test.go +91 -0
@@ -0,0 +1,91 @@
1+package favicon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "testing"
8+
9+ appport "github.com/bnema/dumber/internal/application/port"
10+ domainfavicon "github.com/bnema/dumber/internal/domain/favicon"
11+)
12+
13+func TestBlobStoreWriteReadOverwriteAndInvalidate(t *testing.T) {
14+ ctx := context.Background()
15+ store := NewBlobStore(t.TempDir())
16+ key := domainfavicon.Key("example.com")
17+
18+ if err := store.WriteOriginal(ctx, key, []byte("ico1"), "image/x-icon"); err != nil {
19+ t.Fatal(err)
20+ }
21+ if err := store.WriteOriginal(ctx, key, []byte("ico2"), "image/png"); err != nil {
22+ t.Fatal(err)
23+ }
24+ got, ct, err := store.ReadOriginal(ctx, key)
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ if string(got) != "ico2" || ct != "image/png" {
29+ t.Fatalf("original = %q %q", got, ct)
30+ }
31+
32+ if err = store.WritePNG(ctx, key, []byte("png")); err != nil {
33+ t.Fatal(err)
34+ }
35+ if err = store.WriteSizedPNG(ctx, key, 32, []byte("sized")); err != nil {
36+ t.Fatal(err)
37+ }
38+ if got, _, err = store.ReadPNG(ctx, key); err != nil || !bytes.Equal(got, []byte("png")) {
39+ t.Fatalf("png = %q %v", got, err)
40+ }
41+ if got, _, err = store.ReadSizedPNG(ctx, key, 32); err != nil || !bytes.Equal(got, []byte("sized")) {
42+ t.Fatalf("sized = %q %v", got, err)
43+ }
44+ if err = store.RemoveDerived(ctx, key); err != nil {
45+ t.Fatal(err)
46+ }
47+ _, _, err = store.ReadPNG(ctx, key)
48+ if !errors.Is(err, appport.ErrFaviconMiss) {
49+ t.Fatalf("ReadPNG err = %v", err)
50+ }
51+ _, _, err = store.ReadSizedPNG(ctx, key, 32)
52+ if !errors.Is(err, appport.ErrFaviconMiss) {
53+ t.Fatalf("ReadSizedPNG err = %v", err)
54+ }
55+ if got, _, err = store.ReadOriginal(ctx, key); err != nil || string(got) != "ico2" {
56+ t.Fatalf("original after invalidate = %q %v", got, err)
57+ }
58+}
59+
60+func TestBlobStoreRemoveDerivedNoopsWhenDiskCacheDisabled(t *testing.T) {
61+ store := NewBlobStore("")
62+ if err := store.RemoveDerived(context.Background(), "example.com"); err != nil {
63+ t.Fatal(err)
64+ }
65+}
66+
67+func TestBlobStoreRemoveDerivedDoesNotRemoveSimilarDomainKeys(t *testing.T) {
68+ ctx := context.Background()
69+ store := NewBlobStore(t.TempDir())
70+ if err := store.WriteSizedPNG(ctx, "example.com", 32, []byte("example")); err != nil {
71+ t.Fatal(err)
72+ }
73+ if err := store.WritePNG(ctx, "example.com.au", []byte("other-png")); err != nil {
74+ t.Fatal(err)
75+ }
76+ if err := store.WriteSizedPNG(ctx, "example.com.au", 32, []byte("other-sized")); err != nil {
77+ t.Fatal(err)
78+ }
79+ if err := store.RemoveDerived(ctx, "example.com"); err != nil {
80+ t.Fatal(err)
81+ }
82+ if _, _, err := store.ReadSizedPNG(ctx, "example.com", 32); !errors.Is(err, appport.ErrFaviconMiss) {
83+ t.Fatalf("example.com sized err = %v", err)
84+ }
85+ if got, _, err := store.ReadPNG(ctx, "example.com.au"); err != nil || string(got) != "other-png" {
86+ t.Fatalf("example.com.au png = %q, %v", got, err)
87+ }
88+ if got, _, err := store.ReadSizedPNG(ctx, "example.com.au", 32); err != nil || string(got) != "other-sized" {
89+ t.Fatalf("example.com.au sized = %q, %v", got, err)
90+ }
91+}
A internal/infrastructure/favicon/converter.go +261 -0
@@ -0,0 +1,261 @@
1+package favicon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/binary"
7+ "fmt"
8+ "image"
9+ "image/color"
10+ "image/png"
11+ "mime"
12+ "regexp"
13+ "strconv"
14+ "strings"
15+
16+ appport "github.com/bnema/dumber/internal/application/port"
17+ "golang.org/x/image/draw"
18+)
19+
20+// ImageConverter converts safe favicon image formats into normalized PNGs.
21+type ImageConverter struct{}
22+
23+func NewImageConverter() *ImageConverter { return &ImageConverter{} }
24+
25+func (*ImageConverter) Convert(ctx context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) {
26+ _ = ctx
27+ if len(original) == 0 {
28+ return nil, fmt.Errorf("%w: empty image", appport.ErrFaviconMiss)
29+ }
30+
31+ img, err := decodeFaviconImage(original, normalizeContentType(contentType))
32+ if err != nil {
33+ return nil, err
34+ }
35+ pngBytes, err := encodePNG(img)
36+ if err != nil {
37+ return nil, err
38+ }
39+ out := &appport.ConvertedFavicon{PNG: pngBytes, SizedPNG: make(map[int][]byte)}
40+ for _, size := range sizes {
41+ if size <= 0 {
42+ continue
43+ }
44+ resized, err := resizeToPNG(img, size)
45+ if err != nil {
46+ return nil, err
47+ }
48+ out.SizedPNG[size] = resized
49+ }
50+ return out, nil
51+}
52+
53+func decodeFaviconImage(data []byte, ct string) (image.Image, error) {
54+ switch ct {
55+ case "image/svg+xml":
56+ return renderSimpleSVG(data)
57+ case "image/x-icon", "image/vnd.microsoft.icon", "image/ico":
58+ img, err := decodeICO(data)
59+ if err != nil {
60+ return nil, err
61+ }
62+ return img, nil
63+ case "image/webp":
64+ return renderWEBPPlaceholder(data)
65+ }
66+ if ct != "" && ct != "image/png" && ct != "image/jpeg" && ct != "image/gif" && ct != "image/webp" {
67+ return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct)
68+ }
69+ img, _, err := image.Decode(bytes.NewReader(data))
70+ if err != nil {
71+ return nil, fmt.Errorf("%w: decode favicon: %w", appport.ErrFaviconMiss, err)
72+ }
73+ return img, nil
74+}
75+
76+func normalizeContentType(contentType string) string {
77+ ct, _, err := mime.ParseMediaType(strings.TrimSpace(strings.ToLower(contentType)))
78+ if err == nil {
79+ return ct
80+ }
81+ return strings.TrimSpace(strings.ToLower(contentType))
82+}
83+
84+func encodePNG(img image.Image) ([]byte, error) {
85+ var buf bytes.Buffer
86+ if err := png.Encode(&buf, img); err != nil {
87+ return nil, err
88+ }
89+ return buf.Bytes(), nil
90+}
91+
92+func resizeToPNG(src image.Image, size int) ([]byte, error) {
93+ crop := cropImage(src, centerSquare(src.Bounds()))
94+ dst := image.NewRGBA(image.Rect(0, 0, size, size))
95+ draw.CatmullRom.Scale(dst, dst.Bounds(), crop, crop.Bounds(), draw.Over, nil)
96+ return encodePNG(dst)
97+}
98+
99+func centerSquare(bounds image.Rectangle) image.Rectangle {
100+ w, h := bounds.Dx(), bounds.Dy()
101+ if w > h {
102+ o := (w - h) / 2
103+ return image.Rect(bounds.Min.X+o, bounds.Min.Y, bounds.Min.X+o+h, bounds.Max.Y)
104+ }
105+ if h > w {
106+ o := (h - w) / 2
107+ return image.Rect(bounds.Min.X, bounds.Min.Y+o, bounds.Max.X, bounds.Min.Y+o+w)
108+ }
109+ return bounds
110+}
111+
112+func decodeICO(data []byte) (image.Image, error) {
113+ if len(data) < 6 || binary.LittleEndian.Uint16(data[0:2]) != 0 || binary.LittleEndian.Uint16(data[2:4]) != 1 {
114+ return nil, fmt.Errorf("%w: malformed ico header", appport.ErrFaviconMiss)
115+ }
116+ count := int(binary.LittleEndian.Uint16(data[4:6]))
117+ if count <= 0 || len(data) < 6+count*16 {
118+ return nil, fmt.Errorf("%w: malformed ico directory", appport.ErrFaviconMiss)
119+ }
120+ var best []byte
121+ bestBitCount := 0
122+ bestArea := -1
123+ for i := 0; i < count; i++ {
124+ entry := data[6+i*16 : 6+(i+1)*16]
125+ w, h := int(entry[0]), int(entry[1])
126+ if w == 0 {
127+ w = 256
128+ }
129+ if h == 0 {
130+ h = 256
131+ }
132+ size := int(binary.LittleEndian.Uint32(entry[8:12]))
133+ offset := int(binary.LittleEndian.Uint32(entry[12:16]))
134+ if size <= 0 || offset < 0 || offset+size > len(data) {
135+ continue
136+ }
137+ payload := data[offset : offset+size]
138+ area := w * h
139+ if area > bestArea {
140+ bestArea = area
141+ best = payload
142+ bestBitCount = int(binary.LittleEndian.Uint16(entry[6:8]))
143+ }
144+ }
145+ if len(best) == 0 {
146+ return nil, fmt.Errorf("%w: ico contains no image", appport.ErrFaviconMiss)
147+ }
148+ if bytes.HasPrefix(best, []byte("\x89PNG\r\n\x1a\n")) {
149+ img, err := png.Decode(bytes.NewReader(best))
150+ if err != nil {
151+ return nil, fmt.Errorf("%w: decode ico png: %w", appport.ErrFaviconMiss, err)
152+ }
153+ return img, nil
154+ }
155+ img, err := decodeICODIB(best, bestBitCount)
156+ if err != nil {
157+ return nil, fmt.Errorf("%w: decode ico bitmap: %w", appport.ErrFaviconMiss, err)
158+ }
159+ return img, nil
160+}
161+
162+func decodeICODIB(data []byte, bitCountHint int) (image.Image, error) {
163+ const dibHeaderSize = 40
164+ if len(data) < dibHeaderSize {
165+ return nil, fmt.Errorf("malformed dib header")
166+ }
167+ headerSize := int(binary.LittleEndian.Uint32(data[0:4]))
168+ if headerSize < dibHeaderSize || len(data) < headerSize {
169+ return nil, fmt.Errorf("unsupported dib header")
170+ }
171+ width := int(int32(binary.LittleEndian.Uint32(data[4:8])))
172+ heightAll := int(int32(binary.LittleEndian.Uint32(data[8:12])))
173+ planes := binary.LittleEndian.Uint16(data[12:14])
174+ bitCount := int(binary.LittleEndian.Uint16(data[14:16]))
175+ compression := binary.LittleEndian.Uint32(data[16:20])
176+ if bitCount == 0 {
177+ bitCount = bitCountHint
178+ }
179+ if width <= 0 || heightAll <= 0 || planes != 1 || compression != 0 {
180+ return nil, fmt.Errorf("unsupported dib metadata")
181+ }
182+ height := heightAll / 2
183+ if height <= 0 {
184+ return nil, fmt.Errorf("invalid dib height")
185+ }
186+ bytesPerPixel := bitCount / 8
187+ if bitCount != 24 && bitCount != 32 {
188+ return nil, fmt.Errorf("unsupported dib bit depth %d", bitCount)
189+ }
190+ stride := ((width*bitCount + 31) / 32) * 4
191+ pixelOffset := headerSize
192+ if len(data) < pixelOffset+stride*height {
193+ return nil, fmt.Errorf("truncated dib pixels")
194+ }
195+ img := image.NewNRGBA(image.Rect(0, 0, width, height))
196+ const opaqueAlpha = 255
197+ for y := 0; y < height; y++ {
198+ srcY := height - 1 - y
199+ row := data[pixelOffset+srcY*stride:]
200+ for x := 0; x < width; x++ {
201+ i := x * bytesPerPixel
202+ b, g, r := row[i], row[i+1], row[i+2]
203+ a := byte(opaqueAlpha)
204+ if bitCount == 32 {
205+ a = row[i+3]
206+ }
207+ img.SetNRGBA(x, y, color.NRGBA{R: r, G: g, B: b, A: a})
208+ }
209+ }
210+ return img, nil
211+}
212+
213+var svgSizeRe = regexp.MustCompile(`(?i)\b(width|height)=["']?(\d+)`) // intentionally small safe subset
214+var svgFillRe = regexp.MustCompile(`(?i)\bfill=["']#?([0-9a-f]{6})`)
215+
216+func renderWEBPPlaceholder(data []byte) (image.Image, error) {
217+ if len(data) < 12 || !bytes.HasPrefix(data, []byte("RIFF")) || string(data[8:12]) != "WEBP" {
218+ return nil, fmt.Errorf("%w: malformed webp", appport.ErrFaviconMiss)
219+ }
220+ return solidImage(64, color.RGBA{R: 128, G: 128, B: 128, A: 255}), nil
221+}
222+
223+func renderSimpleSVG(data []byte) (image.Image, error) {
224+ trimmed := bytes.TrimSpace(data)
225+ if !bytes.Contains(trimmed, []byte("<svg")) {
226+ return nil, fmt.Errorf("%w: malformed svg", appport.ErrFaviconMiss)
227+ }
228+ size := 64
229+ for _, m := range svgSizeRe.FindAllSubmatch(trimmed, -1) {
230+ if v, err := strconv.Atoi(string(m[2])); err == nil && v > 0 && v <= 1024 {
231+ size = v
232+ break
233+ }
234+ }
235+ fill := color.RGBA{R: 128, G: 128, B: 128, A: 255}
236+ if m := svgFillRe.FindSubmatch(trimmed); len(m) == 2 {
237+ if parsed, ok := parseHexColor(string(m[1])); ok {
238+ fill = parsed
239+ }
240+ }
241+ return solidImage(size, fill), nil
242+}
243+
244+func solidImage(size int, fill color.RGBA) image.Image {
245+ img := image.NewRGBA(image.Rect(0, 0, size, size))
246+ for y := 0; y < size; y++ {
247+ for x := 0; x < size; x++ {
248+ img.SetRGBA(x, y, fill)
249+ }
250+ }
251+ return img
252+}
253+
254+func parseHexColor(hex string) (color.RGBA, bool) {
255+ v, err := strconv.ParseUint(hex, 16, 32)
256+ if err != nil {
257+ return color.RGBA{}, false
258+ }
259+ const greenShift = 8
260+ return color.RGBA{R: uint8(v >> 16), G: uint8(v >> greenShift), B: uint8(v), A: 255}, true
261+}
A internal/infrastructure/favicon/converter_test.go +137 -0
@@ -0,0 +1,137 @@
1+package favicon
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "image"
8+ "image/color"
9+ "image/png"
10+ "testing"
11+
12+ appport "github.com/bnema/dumber/internal/application/port"
13+)
14+
15+func TestConverterPNGNormalizesAndSizes(t *testing.T) {
16+ conv := NewImageConverter()
17+ out, err := conv.Convert(context.Background(), testPNG(t, 64, 48), "image/png", []int{32})
18+ if err != nil {
19+ t.Fatal(err)
20+ }
21+ if _, decodeErr := png.Decode(bytes.NewReader(out.PNG)); decodeErr != nil {
22+ t.Fatalf("normalized png invalid: %v", decodeErr)
23+ }
24+ img, err := png.Decode(bytes.NewReader(out.SizedPNG[32]))
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ if img.Bounds().Dx() != 32 || img.Bounds().Dy() != 32 {
29+ t.Fatalf("size = %v", img.Bounds())
30+ }
31+}
32+
33+func TestConverterAcceptedFaviconFormatsProducePNGs(t *testing.T) {
34+ conv := NewImageConverter()
35+ for _, tc := range []struct {
36+ name, ct string
37+ data []byte
38+ }{
39+ {"png", "image/png", testPNG(t, 16, 16)},
40+ {"ico with png payload", "image/x-icon", testICOWithPNG(t, testPNG(t, 16, 16))},
41+ {"ico with bitmap payload", "image/x-icon", testICOWithDIB(t)},
42+ {"svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#ff0000"/></svg>`)},
43+ {"webp placeholder", "image/webp", []byte("RIFF\x04\x00\x00\x00WEBPVP8 ")},
44+ } {
45+ t.Run(tc.name, func(t *testing.T) {
46+ out, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32})
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ if _, decodeErr := png.Decode(bytes.NewReader(out.PNG)); decodeErr != nil {
51+ t.Fatalf("normalized png invalid: %v", decodeErr)
52+ }
53+ img, err := png.Decode(bytes.NewReader(out.SizedPNG[32]))
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if img.Bounds().Dx() != 32 || img.Bounds().Dy() != 32 {
58+ t.Fatalf("size = %v", img.Bounds())
59+ }
60+ })
61+ }
62+}
63+
64+func TestConverterMalformedInputsAreMisses(t *testing.T) {
65+ conv := NewImageConverter()
66+ for _, tc := range []struct {
67+ name, ct string
68+ data []byte
69+ }{
70+ {"ico", "image/x-icon", []byte{0, 0, 1, 0}},
71+ {"svg", "image/svg+xml", []byte(`not svg`)},
72+ {"webp", "image/webp", []byte("not webp")},
73+ {"malformed", "image/png", []byte("not an image")},
74+ } {
75+ t.Run(tc.name, func(t *testing.T) {
76+ if _, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32}); !errors.Is(err, appport.ErrFaviconMiss) {
77+ t.Fatalf("err = %v", err)
78+ }
79+ })
80+ }
81+}
82+
83+func testICOWithDIB(t *testing.T) []byte {
84+ t.Helper()
85+ const width, height, bitCount = 2, 2, 32
86+ var dib bytes.Buffer
87+ writeLE32 := func(v uint32) { dib.Write([]byte{byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)}) }
88+ writeLE16 := func(v uint16) { dib.Write([]byte{byte(v), byte(v >> 8)}) }
89+ writeLE32(40) // BITMAPINFOHEADER size
90+ writeLE32(width) // width
91+ writeLE32(height * 2) // xor + and mask height
92+ writeLE16(1) // planes
93+ writeLE16(bitCount) // bit count
94+ writeLE32(0) // BI_RGB
95+ writeLE32(width * height * 4) // image size
96+ writeLE32(0)
97+ writeLE32(0) // pels per meter
98+ writeLE32(0)
99+ writeLE32(0) // colors
100+ dib.Write([]byte{0, 0, 255, 255, 0, 255, 0, 255}) // bottom row BGRA
101+ dib.Write([]byte{255, 0, 0, 255, 0, 0, 0, 255}) // top row BGRA
102+ return testICOWithPayload(t, dib.Bytes(), bitCount)
103+}
104+
105+func testICOWithPNG(t *testing.T, pngData []byte) []byte {
106+ t.Helper()
107+ return testICOWithPayload(t, pngData, 32)
108+}
109+
110+func testICOWithPayload(t *testing.T, payload []byte, bitCount byte) []byte {
111+ t.Helper()
112+ buf := bytes.NewBuffer(nil)
113+ buf.Write([]byte{0, 0, 1, 0, 1, 0}) // reserved, type icon, count
114+ buf.Write([]byte{16, 16, 0, 0}) // width, height, colors, reserved
115+ buf.Write([]byte{1, 0, bitCount, 0}) // planes, bit count
116+ size := uint32(len(payload))
117+ offset := uint32(6 + 16)
118+ buf.Write([]byte{byte(size), byte(size >> 8), byte(size >> 16), byte(size >> 24)})
119+ buf.Write([]byte{byte(offset), byte(offset >> 8), byte(offset >> 16), byte(offset >> 24)})
120+ buf.Write(payload)
121+ return buf.Bytes()
122+}
123+
124+func testPNG(t *testing.T, w, h int) []byte {
125+ t.Helper()
126+ img := image.NewRGBA(image.Rect(0, 0, w, h))
127+ for y := 0; y < h; y++ {
128+ for x := 0; x < w; x++ {
129+ img.Set(x, y, color.RGBA{R: 200, A: 255})
130+ }
131+ }
132+ var buf bytes.Buffer
133+ if err := png.Encode(&buf, img); err != nil {
134+ t.Fatal(err)
135+ }
136+ return buf.Bytes()
137+}
M internal/infrastructure/favicon/fetcher.go +246 -38
@@ -2,82 +2,290 @@
22 package favicon
33
44 import (
5+ "bytes"
56 "context"
7+ "errors"
68 "fmt"
79 "io"
10+ "net"
811 "net/http"
12+ "net/netip"
913 "net/url"
14+ "strings"
1015 "time"
1116
12- "github.com/bnema/dumber/internal/logging"
17+ appport "github.com/bnema/dumber/internal/application/port"
18+ domainfavicon "github.com/bnema/dumber/internal/domain/favicon"
1319 )
1420
1521 const (
16- // DuckDuckGo favicon API URL template.
1722 duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico"
18- // HTTP client timeout for favicon fetch.
19- fetchTimeout = 5 * time.Second
23+ fetchTimeout = 5 * time.Second
24+ maxFaviconBytes = 1 << 20
25+ maxFaviconRedirects = 3
26+ sniffLen = 512
2027 )
2128
22-// Fetcher retrieves favicons from external APIs.
29+type fetchContextKey string
30+
31+const allowedLocalOriginKey fetchContextKey = "allowed-local-origin"
32+
2333 type Fetcher struct {
24- client *http.Client
34+ client *http.Client
35+ duckDuckGoURL string
36+ maxBytes int64
2537 }
2638
27-// NewFetcher creates a new Fetcher with default HTTP client settings.
2839 func NewFetcher() *Fetcher {
29- return &Fetcher{
30- client: &http.Client{
31- Timeout: fetchTimeout,
32- },
33- }
40+ f := &Fetcher{duckDuckGoURL: duckduckgoFaviconURL, maxBytes: maxFaviconBytes}
41+ transport := http.DefaultTransport.(*http.Transport).Clone()
42+ transport.DialContext = f.safeDialContext
43+ f.client = &http.Client{Timeout: fetchTimeout, Transport: transport, CheckRedirect: f.checkRedirect}
44+ return f
3445 }
3546
36-// Fetch retrieves favicon bytes for a domain from DuckDuckGo's favicon API.
37-// Returns nil bytes if the favicon cannot be fetched.
38-func (f *Fetcher) Fetch(ctx context.Context, domain string) ([]byte, error) {
39- if domain == "" {
40- return nil, nil
47+func NewFetcherWithClient(client *http.Client, duckDuckGoURL string) *Fetcher {
48+ f := &Fetcher{client: client, duckDuckGoURL: duckDuckGoURL, maxBytes: maxFaviconBytes}
49+ if f.client == nil {
50+ f.client = &http.Client{Timeout: fetchTimeout}
4151 }
52+ f.client.Transport = f.safeTransport(f.client.Transport)
53+ f.client.CheckRedirect = f.checkRedirect
54+ if f.duckDuckGoURL == "" {
55+ f.duckDuckGoURL = duckduckgoFaviconURL
56+ }
57+ return f
58+}
4259
43- log := logging.FromContext(ctx)
44- faviconURL := fmt.Sprintf(duckduckgoFaviconURL, url.QueryEscape(domain))
45-
46- log.Debug().Str("url", faviconURL).Msg("fetching favicon from DuckDuckGo")
60+func (f *Fetcher) safeTransport(base http.RoundTripper) http.RoundTripper {
61+ transport, ok := base.(*http.Transport)
62+ if !ok || transport == nil {
63+ transport = http.DefaultTransport.(*http.Transport)
64+ }
65+ clone := transport.Clone()
66+ clone.DialContext = f.safeDialContext
67+ return clone
68+}
4769
48- req, err := http.NewRequestWithContext(ctx, http.MethodGet, faviconURL, http.NoBody)
70+func (f *Fetcher) Fetch(ctx context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
71+ fetchURL, source, err := f.resolveURL(req)
4972 if err != nil {
50- log.Debug().Err(err).Msg("failed to create favicon request")
5173 return nil, err
5274 }
75+ data, ct, err := f.fetchURL(ctx, fetchURL, req.PageURL)
76+ if err != nil {
77+ return nil, err
78+ }
79+ return &appport.FaviconFetchedIcon{PageURL: req.PageURL, IconURL: fetchURL, Bytes: data, Source: source, ContentType: ct}, nil
80+}
5381
54- resp, err := f.client.Do(req)
82+// FetchDomain preserves the old DuckDuckGo-only API for legacy callers.
83+func (f *Fetcher) FetchDomain(ctx context.Context, domain string) ([]byte, error) {
84+ if strings.TrimSpace(domain) == "" {
85+ return nil, nil
86+ }
87+ got, err := f.Fetch(ctx, appport.FaviconFetchRequest{PageURL: "https://" + domain})
88+ if errors.Is(err, appport.ErrFaviconMiss) {
89+ return nil, nil
90+ }
5591 if err != nil {
56- log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo")
5792 return nil, err
5893 }
59- defer func() {
60- if closeErr := resp.Body.Close(); closeErr != nil {
61- log.Debug().Err(closeErr).Str("domain", domain).Msg("failed to close favicon response body")
94+ return got.Bytes, nil
95+}
96+
97+func (f *Fetcher) resolveURL(req appport.FaviconFetchRequest) (string, domainfavicon.Source, error) {
98+ if req.IconURL != "" {
99+ u, err := url.Parse(req.IconURL)
100+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
101+ return "", "", appport.ErrFaviconMiss
102+ }
103+ if !allowedURLForPage(req.PageURL, u) {
104+ return "", "", appport.ErrFaviconMiss
62105 }
63- }()
106+ return u.String(), domainfavicon.SourcePageDiscovery, nil
107+ }
108+ key, ok := domainfavicon.CanonicalKey(req.PageURL)
109+ if !ok {
110+ return "", "", appport.ErrFaviconMiss
111+ }
112+ return fmt.Sprintf(f.duckDuckGoURL, url.QueryEscape(string(key))), domainfavicon.SourceDuckDuckGo, nil
113+}
64114
115+func (f *Fetcher) fetchURL(ctx context.Context, raw, pageURL string) ([]byte, string, error) {
116+ ctx = context.WithValue(ctx, allowedLocalOriginKey, allowedLocalOrigin(pageURL))
117+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, http.NoBody)
118+ if err != nil {
119+ return nil, "", err
120+ }
121+ resp, err := f.client.Do(req)
122+ if err != nil {
123+ return nil, "", err
124+ }
125+ defer func() { _ = resp.Body.Close() }()
65126 if resp.StatusCode != http.StatusOK {
66- log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status")
67- return nil, nil
127+ return nil, "", appport.ErrFaviconMiss
128+ }
129+ limit := f.maxBytes
130+ if limit <= 0 {
131+ limit = maxFaviconBytes
132+ }
133+ body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
134+ if err != nil {
135+ return nil, "", err
136+ }
137+ if int64(len(body)) > limit {
138+ return nil, "", fmt.Errorf("favicon response exceeds %d bytes", limit)
139+ }
140+ if len(body) == 0 {
141+ return nil, "", appport.ErrFaviconMiss
142+ }
143+ declared := normalizeContentType(resp.Header.Get("Content-Type"))
144+ if declared == "" || declared == "application/octet-stream" {
145+ declared = normalizeContentType(http.DetectContentType(body[:min(len(body), sniffLen)]))
146+ }
147+ if !supportedFetchedContentType(declared) || !bodyMatchesContentType(declared, body) {
148+ return nil, "", appport.ErrFaviconMiss
149+ }
150+ return body, declared, nil
151+}
152+
153+func (*Fetcher) checkRedirect(req *http.Request, via []*http.Request) error {
154+ if len(via) >= maxFaviconRedirects {
155+ return http.ErrUseLastResponse
156+ }
157+ if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
158+ return http.ErrUseLastResponse
68159 }
160+ if !allowedURLForOrigin(req.Context(), req.URL) {
161+ return http.ErrUseLastResponse
162+ }
163+ return nil
164+}
69165
70- data, err := io.ReadAll(resp.Body)
166+func (*Fetcher) safeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
167+ host, port, err := net.SplitHostPort(address)
168+ if err != nil {
169+ return nil, err
170+ }
171+ allowed := ctx.Value(allowedLocalOriginKey) == normalizeOriginHostPort(host, port, "")
172+ ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
71173 if err != nil {
72- log.Debug().Err(err).Str("domain", domain).Msg("failed to read favicon response")
73174 return nil, err
74175 }
176+ var dialIP net.IP
177+ for _, ip := range ips {
178+ addr, ok := netip.AddrFromSlice(ip.IP)
179+ if (!ok || isPrivateAddr(addr)) && !allowed {
180+ continue
181+ }
182+ dialIP = ip.IP
183+ break
184+ }
185+ if dialIP == nil {
186+ return nil, fmt.Errorf("no allowed resolved address for favicon host %s", host)
187+ }
188+ var d net.Dialer
189+ return d.DialContext(ctx, network, net.JoinHostPort(dialIP.String(), port))
190+}
75191
76- if len(data) == 0 {
77- log.Debug().Str("domain", domain).Msg("empty favicon response from DuckDuckGo")
78- return nil, nil
192+func supportedFetchedContentType(ct string) bool {
193+ switch ct {
194+ case "image/png", "image/x-icon", "image/vnd.microsoft.icon", "image/jpeg", "image/gif", "image/svg+xml", "image/webp":
195+ return true
196+ }
197+ return false
198+}
199+
200+func bodyMatchesContentType(ct string, body []byte) bool {
201+ switch ct {
202+ case "image/png":
203+ return bytes.HasPrefix(body, []byte("\x89PNG\r\n\x1a\n"))
204+ case "image/jpeg":
205+ return len(body) >= 3 && body[0] == 0xff && body[1] == 0xd8 && body[2] == 0xff
206+ case "image/gif":
207+ return bytes.HasPrefix(body, []byte("GIF87a")) || bytes.HasPrefix(body, []byte("GIF89a"))
208+ case "image/x-icon", "image/vnd.microsoft.icon":
209+ return len(body) >= 4 && body[0] == 0 && body[1] == 0 && (body[2] == 1 || body[2] == 2) && body[3] == 0
210+ case "image/webp":
211+ return len(body) >= 12 && bytes.HasPrefix(body, []byte("RIFF")) && string(body[8:12]) == "WEBP"
212+ case "image/svg+xml":
213+ trimmed := bytes.TrimSpace(body)
214+ return bytes.HasPrefix(trimmed, []byte("<svg")) || (bytes.HasPrefix(trimmed, []byte("<?xml")) && bytes.Contains(trimmed, []byte("<svg")))
215+ default:
216+ return false
217+ }
218+}
219+
220+func allowedURLForPage(pageURL string, iconURL *url.URL) bool {
221+ if !isInternalHost(iconURL.Hostname()) {
222+ return true
223+ }
224+ page, err := url.Parse(pageURL)
225+ if err != nil {
226+ return false
227+ }
228+ return isLocalhost(page.Hostname()) && sameOrigin(page, iconURL)
229+}
230+
231+func allowedURLForOrigin(ctx context.Context, target *url.URL) bool {
232+ if !isInternalHost(target.Hostname()) {
233+ return true
79234 }
235+ allowed, _ := ctx.Value(allowedLocalOriginKey).(string)
236+ targetOrigin := normalizeOriginHostPort(target.Hostname(), target.Port(), target.Scheme)
237+ return allowed != "" && strings.EqualFold(allowed, targetOrigin)
238+}
239+
240+func allowedLocalOrigin(pageURL string) string {
241+ page, err := url.Parse(pageURL)
242+ if err != nil || !isLocalhost(page.Hostname()) {
243+ return ""
244+ }
245+ return normalizeOriginHostPort(page.Hostname(), page.Port(), page.Scheme)
246+}
247+
248+func sameOrigin(a, b *url.URL) bool {
249+ return strings.EqualFold(a.Scheme, b.Scheme) &&
250+ normalizeOriginHostPort(a.Hostname(), a.Port(), a.Scheme) == normalizeOriginHostPort(b.Hostname(), b.Port(), b.Scheme)
251+}
252+
253+func normalizeOriginHostPort(host, port, scheme string) string {
254+ if port == "" {
255+ switch strings.ToLower(scheme) {
256+ case "http":
257+ port = "80"
258+ case "https":
259+ port = "443"
260+ }
261+ }
262+ if port == "" {
263+ return strings.ToLower(host)
264+ }
265+ return strings.ToLower(net.JoinHostPort(host, port))
266+}
267+
268+func isInternalHost(host string) bool {
269+ if isLocalhost(host) {
270+ return true
271+ }
272+ ips, err := net.LookupIP(host)
273+ if err != nil {
274+ return true
275+ }
276+ for _, ip := range ips {
277+ addr, ok := netip.AddrFromSlice(ip)
278+ if !ok || isPrivateAddr(addr) {
279+ return true
280+ }
281+ }
282+ return false
283+}
284+
285+func isLocalhost(host string) bool {
286+ return strings.EqualFold(host, "localhost") || host == "127.0.0.1" || host == "::1"
287+}
80288
81- log.Debug().Str("domain", domain).Int("bytes", len(data)).Msg("favicon fetched from DuckDuckGo")
82- return data, nil
289+func isPrivateAddr(addr netip.Addr) bool {
290+ return addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsUnspecified()
83291 }
A internal/infrastructure/favicon/fetcher_test.go +119 -0
@@ -0,0 +1,119 @@
1+package favicon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "net/url"
10+ "testing"
11+
12+ appport "github.com/bnema/dumber/internal/application/port"
13+)
14+
15+func TestFetcherExplicitIconURLAndDuckDuckGoFallback(t *testing.T) {
16+ png := testPNG(t, 8, 8)
17+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
18+ w.Header().Set("Content-Type", "image/png")
19+ _, _ = w.Write(png)
20+ }))
21+ defer srv.Close()
22+ fetcher := NewFetcherWithClient(srv.Client(), srv.URL+"/%s.ico")
23+
24+ got, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: srv.URL + "/page", IconURL: srv.URL + "/icon.png"})
25+ if err != nil || got.ContentType != "image/png" || len(got.Bytes) == 0 {
26+ t.Fatalf("explicit got=%+v err=%v", got, err)
27+ }
28+ got, err = fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com/page"})
29+ if err != nil || got.Source == "" || len(got.Bytes) == 0 {
30+ t.Fatalf("fallback got=%+v err=%v", got, err)
31+ }
32+}
33+
34+func TestFetcherMissesAndGuards(t *testing.T) {
35+ fetcher := NewFetcher()
36+ if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com", IconURL: "file:///x"}); !errors.Is(err, appport.ErrFaviconMiss) {
37+ t.Fatalf("scheme err=%v", err)
38+ }
39+ if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com", IconURL: "http://127.0.0.1/icon.png"}); !errors.Is(err, appport.ErrFaviconMiss) {
40+ t.Fatalf("ssrf err=%v", err)
41+ }
42+
43+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusNotFound) }))
44+ defer srv.Close()
45+ fetcher = NewFetcherWithClient(srv.Client(), "")
46+ if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: srv.URL, IconURL: srv.URL + "/missing"}); !errors.Is(err, appport.ErrFaviconMiss) {
47+ t.Fatalf("404 err=%v", err)
48+ }
49+}
50+
51+func TestFetcherRejectsMismatchedDeclaredContentType(t *testing.T) {
52+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
53+ w.Header().Set("Content-Type", "image/png")
54+ _, _ = w.Write([]byte("<html>not a png</html>"))
55+ }))
56+ defer srv.Close()
57+ fetcher := NewFetcherWithClient(srv.Client(), "")
58+ if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: srv.URL, IconURL: srv.URL + "/icon.png"}); !errors.Is(err, appport.ErrFaviconMiss) {
59+ t.Fatalf("mismatched content err=%v", err)
60+ }
61+}
62+
63+func TestFetcherLocalhostExceptionRequiresSameOrigin(t *testing.T) {
64+ page := mustParseURL(t, "http://localhost:3000/page")
65+ same := mustParseURL(t, "http://localhost:3000/favicon.ico")
66+ otherPort := mustParseURL(t, "http://localhost:8080/favicon.ico")
67+ otherScheme := mustParseURL(t, "https://localhost:3000/favicon.ico")
68+ defaultPortPage := mustParseURL(t, "http://localhost/page")
69+ defaultPortIcon := mustParseURL(t, "http://localhost:80/favicon.ico")
70+ if !allowedURLForPage(page.String(), same) {
71+ t.Fatal("same-origin localhost should be allowed")
72+ }
73+ if allowedURLForPage(page.String(), otherPort) {
74+ t.Fatal("different localhost port should be rejected")
75+ }
76+ if allowedURLForPage(page.String(), otherScheme) {
77+ t.Fatal("different localhost scheme should be rejected")
78+ }
79+ if !allowedURLForPage(defaultPortPage.String(), defaultPortIcon) {
80+ t.Fatal("default localhost port should normalize as same-origin")
81+ }
82+ if got := allowedLocalOrigin(defaultPortPage.String()); got != "localhost:80" {
83+ t.Fatalf("allowedLocalOrigin default port = %q, want localhost:80", got)
84+ }
85+ if !allowedURLForOrigin(context.WithValue(context.Background(), allowedLocalOriginKey, allowedLocalOrigin(defaultPortPage.String())), mustParseURL(t, "http://localhost/favicon.ico")) {
86+ t.Fatal("same-origin localhost redirect with implicit default port should be allowed")
87+ }
88+}
89+
90+func mustParseURL(t *testing.T, raw string) *url.URL {
91+ t.Helper()
92+ u, err := url.Parse(raw)
93+ if err != nil {
94+ t.Fatal(err)
95+ }
96+ return u
97+}
98+
99+func TestFetcherMaxSizeAndRedirectLimit(t *testing.T) {
100+ big := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
101+ w.Header().Set("Content-Type", "image/png")
102+ _, _ = w.Write(make([]byte, 20))
103+ }))
104+ defer big.Close()
105+ fetcher := NewFetcherWithClient(big.Client(), "")
106+ fetcher.maxBytes = 4
107+ if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: big.URL, IconURL: big.URL}); err == nil {
108+ t.Fatal("expected max size error")
109+ }
110+
111+ redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112+ http.Redirect(w, r, fmt.Sprintf("/r%s", r.URL.Path), http.StatusFound)
113+ }))
114+ defer redir.Close()
115+ fetcher = NewFetcherWithClient(redir.Client(), "")
116+ if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: redir.URL, IconURL: redir.URL + "/r"}); !errors.Is(err, appport.ErrFaviconMiss) {
117+ t.Fatalf("redirect err=%v", err)
118+ }
119+}
A internal/infrastructure/favicon/scheduler.go +40 -0
@@ -0,0 +1,40 @@
1+package favicon
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/domain/favicon"
8+)
9+
10+type RefreshScheduler struct {
11+ mu sync.Mutex
12+ running map[favicon.Key]struct{}
13+}
14+
15+func NewRefreshScheduler() *RefreshScheduler {
16+ return &RefreshScheduler{running: make(map[favicon.Key]struct{})}
17+}
18+
19+func (s *RefreshScheduler) Schedule(ctx context.Context, key favicon.Key, work func(context.Context)) bool {
20+ if s == nil || key == "" || work == nil {
21+ return false
22+ }
23+ s.mu.Lock()
24+ if _, ok := s.running[key]; ok {
25+ s.mu.Unlock()
26+ return false
27+ }
28+ s.running[key] = struct{}{}
29+ s.mu.Unlock()
30+
31+ go func() {
32+ defer func() {
33+ s.mu.Lock()
34+ delete(s.running, key)
35+ s.mu.Unlock()
36+ }()
37+ work(ctx)
38+ }()
39+ return true
40+}
M internal/infrastructure/favicon/service.go +20 -69
@@ -15,24 +15,27 @@ const InternalScheme = "dumb://"
1515 // InternalDomain is the pseudo-domain used for caching internal page favicons.
1616 const InternalDomain = "dumb"
1717
18-// Service implements the domain FaviconService interface.
19-// It coordinates between the cache and fetcher components.
18+// Service is the legacy favicon cache adapter kept for callers that have not yet
19+// been migrated to the application favicon usecase. It must not own refresh or
20+// remote-fetch policy; new code should use the usecase plus BlobStore/Fetcher.
2021 type Service struct {
21- cache *Cache
22- fetcher *Fetcher
22+ cache *Cache
2323 }
2424
2525 // NewService creates a new favicon service.
2626 // cacheDir is the directory for disk caching; empty string disables disk caching.
2727 func NewService(cacheDir string) *Service {
28- return &Service{
29- cache: NewCache(cacheDir),
30- fetcher: NewFetcher(),
28+ return &Service{cache: NewCache(cacheDir)}
29+}
30+
31+func (s *Service) Cache() *Cache {
32+ if s == nil {
33+ return nil
3134 }
35+ return s.cache
3236 }
3337
34-// Get returns favicon bytes for a domain.
35-// Checks memory cache, then disk cache, then fetches from external API.
38+// Get returns favicon bytes for a domain from the legacy cache only.
3639 func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
3740 if domain == "" {
3841 return nil, nil
@@ -46,7 +49,6 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
4649 return assets.LogoSVG, nil
4750 }
4851
49- // Check cache first (memory + disk)
5052 if data, ok := s.cache.Get(ctx, domain); ok {
5153 log.Debug().
5254 Str("domain", domain).
@@ -54,24 +56,8 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
5456 Msg("favicon: Service.Get cache hit")
5557 return data, nil
5658 }
57-
58- // Fetch from external API
59- data, err := s.fetcher.Fetch(ctx, domain)
60- if err != nil {
61- log.Debug().Err(err).Str("domain", domain).Msg("favicon: Service.Get fetch error")
62- return nil, err
63- }
64-
65- // Store in cache if we got data
66- if len(data) > 0 {
67- s.cache.Set(ctx, domain, data)
68- }
69- log.Debug().
70- Str("domain", domain).
71- Int("bytes", len(data)).
72- Msg("favicon: Service.Get end")
73-
74- return data, nil
59+ log.Debug().Str("domain", domain).Msg("favicon: Service.Get cache miss")
60+ return nil, nil
7561 }
7662
7763 // IsInternalURL checks if a URL uses the internal dumb:// scheme.
@@ -127,35 +113,10 @@ func (s *Service) Close() {
127113 s.cache.Close()
128114 }
129115
130-// EnsureDiskCache fetches favicon from external API and saves to disk if not already cached.
131-// This is used when we receive a texture from WebKit but want to ensure disk persistence.
132-func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {
133- if domain == "" {
134- return
135- }
136- log := logging.FromContext(ctx)
137- log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache begin")
138-
139- // Already on disk?
140- if s.cache.HasOnDisk(domain) {
141- log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache already on disk")
142- return
143- }
144-
145- // Fetch and store
146- data, err := s.fetcher.Fetch(ctx, domain)
147- if err != nil {
148- log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon for disk cache")
149- return
150- }
151-
152- if len(data) > 0 {
153- s.cache.Set(ctx, domain, data)
154- }
155- log.Debug().
156- Str("domain", domain).
157- Int("bytes", len(data)).
158- Msg("favicon: EnsureDiskCache end")
116+// EnsureDiskCache is a legacy no-op. Disk population is handled by explicit
117+// texture/blob writes after EnsureCacheDir; remote refresh policy belongs to the application usecase.
118+func (*Service) EnsureDiskCache(ctx context.Context, domain string) {
119+ logging.FromContext(ctx).Debug().Str("domain", domain).Msg("favicon: legacy EnsureDiskCache skipped")
159120 }
160121
161122 // DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
@@ -168,8 +129,8 @@ func (s *Service) HasPNGSizedOnDisk(domain string, size int) bool {
168129 return s.cache.HasPNGSizedOnDisk(domain, size)
169130 }
170131
171-// EnsureSizedPNG creates a resized PNG from the original if it doesn't exist.
172-// This is used to generate normalized icons for dmenu/fuzzel.
132+// EnsureSizedPNG creates or overwrites a resized PNG from the original PNG.
133+// This is a legacy adapter method; new code should use the favicon usecase.
173134 func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) error {
174135 if domain == "" {
175136 return nil
@@ -180,16 +141,6 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e
180141 Int("size", size).
181142 Msg("favicon: EnsureSizedPNG begin")
182143
183- // Already exists?
184- if s.HasPNGSizedOnDisk(domain, size) {
185- log.Debug().
186- Str("domain", domain).
187- Int("size", size).
188- Msg("favicon: EnsureSizedPNG already exists")
189- return nil
190- }
191-
192- // Check source PNG exists
193144 srcPath := s.DiskPathPNG(domain)
194145 if srcPath == "" || !s.HasPNGOnDisk(domain) {
195146 return fmt.Errorf("source PNG not found for domain %s", domain)
A internal/infrastructure/persistence/sqlite/favicon_repo.go +141 -0
@@ -0,0 +1,141 @@
1+package sqlite
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "errors"
7+ "sync"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/favicon"
12+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
13+)
14+
15+type faviconRepo struct {
16+ queries *sqlc.Queries
17+}
18+
19+func NewFaviconRepository(db *sql.DB) port.FaviconRepository {
20+ return &faviconRepo{queries: sqlc.New(db)}
21+}
22+
23+type lazyFaviconRepository struct {
24+ provider port.DatabaseProvider
25+ repo port.FaviconRepository
26+ once sync.Once
27+ initErr error
28+}
29+
30+func NewLazyFaviconRepository(provider port.DatabaseProvider) port.FaviconRepository {
31+ return &lazyFaviconRepository{provider: provider}
32+}
33+
34+func (r *lazyFaviconRepository) init(ctx context.Context) error {
35+ r.once.Do(func() {
36+ db, err := r.provider.DB(ctx)
37+ if err != nil {
38+ r.initErr = err
39+ return
40+ }
41+ r.repo = NewFaviconRepository(db)
42+ })
43+ return r.initErr
44+}
45+
46+func (r *lazyFaviconRepository) Get(ctx context.Context, key favicon.Key) (*favicon.Metadata, error) {
47+ if err := r.init(ctx); err != nil {
48+ return nil, err
49+ }
50+ return r.repo.Get(ctx, key)
51+}
52+
53+func (r *lazyFaviconRepository) FindFirst(ctx context.Context, keys []favicon.Key) (*favicon.Metadata, error) {
54+ if err := r.init(ctx); err != nil {
55+ return nil, err
56+ }
57+ return r.repo.FindFirst(ctx, keys)
58+}
59+
60+func (r *lazyFaviconRepository) Upsert(ctx context.Context, meta favicon.Metadata) error {
61+ if err := r.init(ctx); err != nil {
62+ return err
63+ }
64+ return r.repo.Upsert(ctx, meta)
65+}
66+
67+func (r *lazyFaviconRepository) UpdateLastChecked(ctx context.Context, key favicon.Key, contentHash string, checkedAt time.Time) error {
68+ if err := r.init(ctx); err != nil {
69+ return err
70+ }
71+ return r.repo.UpdateLastChecked(ctx, key, contentHash, checkedAt)
72+}
73+
74+func (r *lazyFaviconRepository) Delete(ctx context.Context, key favicon.Key) error {
75+ if err := r.init(ctx); err != nil {
76+ return err
77+ }
78+ return r.repo.Delete(ctx, key)
79+}
80+
81+func (r *faviconRepo) Get(ctx context.Context, key favicon.Key) (*favicon.Metadata, error) {
82+ row, err := r.queries.GetFavicon(ctx, string(key))
83+ if err != nil {
84+ if errors.Is(err, sql.ErrNoRows) {
85+ return nil, nil
86+ }
87+ return nil, err
88+ }
89+ return faviconFromRow(row), nil
90+}
91+
92+func (r *faviconRepo) FindFirst(ctx context.Context, keys []favicon.Key) (*favicon.Metadata, error) {
93+ for _, key := range keys {
94+ meta, err := r.Get(ctx, key)
95+ if err != nil {
96+ return nil, err
97+ }
98+ if meta != nil {
99+ return meta, nil
100+ }
101+ }
102+ return nil, nil
103+}
104+
105+func (r *faviconRepo) Upsert(ctx context.Context, meta favicon.Metadata) error {
106+ return r.queries.UpsertFavicon(ctx, sqlc.UpsertFaviconParams{
107+ Key: string(meta.Key),
108+ SourceUrl: nullString(meta.SourceURL),
109+ PageUrl: nullString(meta.PageURL),
110+ Source: string(meta.Source),
111+ ContentHash: meta.ContentHash,
112+ ContentType: nullString(meta.ContentType),
113+ UpdatedAt: meta.UpdatedAt,
114+ LastCheckedAt: meta.LastCheckedAt,
115+ })
116+}
117+
118+func (r *faviconRepo) UpdateLastChecked(ctx context.Context, key favicon.Key, contentHash string, checkedAt time.Time) error {
119+ return r.queries.UpdateFaviconLastChecked(ctx, sqlc.UpdateFaviconLastCheckedParams{
120+ Key: string(key),
121+ ContentHash: contentHash,
122+ LastCheckedAt: checkedAt,
123+ })
124+}
125+
126+func (r *faviconRepo) Delete(ctx context.Context, key favicon.Key) error {
127+ return r.queries.DeleteFavicon(ctx, string(key))
128+}
129+
130+func faviconFromRow(row sqlc.Favicon) *favicon.Metadata {
131+ return &favicon.Metadata{
132+ Key: favicon.Key(row.Key),
133+ SourceURL: row.SourceUrl.String,
134+ PageURL: row.PageUrl.String,
135+ Source: favicon.Source(row.Source),
136+ ContentHash: row.ContentHash,
137+ ContentType: row.ContentType.String,
138+ UpdatedAt: row.UpdatedAt,
139+ LastCheckedAt: row.LastCheckedAt,
140+ }
141+}
A internal/infrastructure/persistence/sqlite/favicon_repo_test.go +130 -0
@@ -0,0 +1,130 @@
1+package sqlite_test
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/favicon"
11+ "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
12+ "github.com/bnema/dumber/internal/logging"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func faviconTestCtx() context.Context {
18+ logger := logging.NewFromConfigValues("debug", "console")
19+ return logging.WithContext(context.Background(), logger)
20+}
21+
22+func newFaviconRepo(t *testing.T) (context.Context, port.FaviconRepository) {
23+ t.Helper()
24+ ctx := faviconTestCtx()
25+ db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
26+ require.NoError(t, err)
27+ t.Cleanup(func() { require.NoError(t, db.Close()) })
28+ return ctx, sqlite.NewFaviconRepository(db)
29+}
30+
31+func TestFaviconRepositoryUpsertAndGet(t *testing.T) {
32+ ctx, repo := newFaviconRepo(t)
33+ updatedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
34+ checkedAt := updatedAt.Add(time.Hour)
35+
36+ meta := favicon.Metadata{
37+ Key: favicon.Key("example.com"),
38+ SourceURL: "https://example.com/favicon.ico",
39+ PageURL: "https://example.com/page",
40+ Source: favicon.SourceEngine,
41+ ContentHash: "sha256:first",
42+ ContentType: "image/png",
43+ UpdatedAt: updatedAt,
44+ LastCheckedAt: checkedAt,
45+ }
46+ require.NoError(t, repo.Upsert(ctx, meta))
47+
48+ got, err := repo.Get(ctx, favicon.Key("example.com"))
49+ require.NoError(t, err)
50+ require.NotNil(t, got)
51+ assert.Equal(t, meta.Key, got.Key)
52+ assert.Equal(t, meta.SourceURL, got.SourceURL)
53+ assert.Equal(t, meta.PageURL, got.PageURL)
54+ assert.Equal(t, meta.Source, got.Source)
55+ assert.Equal(t, meta.ContentHash, got.ContentHash)
56+ assert.Equal(t, meta.ContentType, got.ContentType)
57+ assert.True(t, meta.UpdatedAt.Equal(got.UpdatedAt), "updated_at = %s", got.UpdatedAt)
58+ assert.True(t, meta.LastCheckedAt.Equal(got.LastCheckedAt), "last_checked_at = %s", got.LastCheckedAt)
59+
60+ replacement := meta
61+ replacement.SourceURL = "https://cdn.example.com/icon.png"
62+ replacement.Source = favicon.SourcePageDiscovery
63+ replacement.ContentHash = "sha256:second"
64+ replacement.UpdatedAt = updatedAt.Add(2 * time.Hour)
65+ replacement.LastCheckedAt = checkedAt.Add(2 * time.Hour)
66+ require.NoError(t, repo.Upsert(ctx, replacement))
67+
68+ got, err = repo.Get(ctx, favicon.Key("example.com"))
69+ require.NoError(t, err)
70+ require.NotNil(t, got)
71+ assert.Equal(t, replacement.SourceURL, got.SourceURL)
72+ assert.Equal(t, replacement.Source, got.Source)
73+ assert.Equal(t, replacement.ContentHash, got.ContentHash)
74+ assert.True(t, replacement.UpdatedAt.Equal(got.UpdatedAt))
75+ assert.True(t, replacement.LastCheckedAt.Equal(got.LastCheckedAt))
76+}
77+
78+func TestFaviconRepositoryGetNotFoundReturnsNil(t *testing.T) {
79+ ctx, repo := newFaviconRepo(t)
80+
81+ got, err := repo.Get(ctx, favicon.Key("missing.example"))
82+ require.NoError(t, err)
83+ assert.Nil(t, got)
84+}
85+
86+func TestFaviconRepositoryFindFirstUsesCallerCandidateOrder(t *testing.T) {
87+ ctx, repo := newFaviconRepo(t)
88+ now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
89+ require.NoError(t, repo.Upsert(ctx, favicon.Metadata{Key: favicon.Key("example.com"), Source: favicon.SourceEngine, ContentHash: "parent", UpdatedAt: now, LastCheckedAt: now}))
90+ require.NoError(t, repo.Upsert(ctx, favicon.Metadata{Key: favicon.Key("docs.example.com"), Source: favicon.SourceEngine, ContentHash: "child", UpdatedAt: now, LastCheckedAt: now}))
91+
92+ got, err := repo.FindFirst(ctx, []favicon.Key{favicon.Key("missing.example.com"), favicon.Key("example.com"), favicon.Key("docs.example.com")})
93+ require.NoError(t, err)
94+ require.NotNil(t, got)
95+ assert.Equal(t, favicon.Key("example.com"), got.Key)
96+ assert.Equal(t, "parent", got.ContentHash)
97+
98+ got, err = repo.FindFirst(ctx, nil)
99+ require.NoError(t, err)
100+ assert.Nil(t, got)
101+}
102+
103+func TestFaviconRepositoryUpdateLastChecked(t *testing.T) {
104+ ctx, repo := newFaviconRepo(t)
105+ updatedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
106+ checkedAt := updatedAt.Add(24 * time.Hour)
107+ meta := favicon.Metadata{Key: favicon.Key("example.com"), Source: favicon.SourceEngine, ContentHash: "old", UpdatedAt: updatedAt, LastCheckedAt: updatedAt}
108+ require.NoError(t, repo.Upsert(ctx, meta))
109+
110+ require.NoError(t, repo.UpdateLastChecked(ctx, favicon.Key("example.com"), "new", checkedAt))
111+
112+ got, err := repo.Get(ctx, favicon.Key("example.com"))
113+ require.NoError(t, err)
114+ require.NotNil(t, got)
115+ assert.Equal(t, "new", got.ContentHash)
116+ assert.True(t, updatedAt.Equal(got.UpdatedAt), "updated_at should not change")
117+ assert.True(t, checkedAt.Equal(got.LastCheckedAt))
118+}
119+
120+func TestFaviconRepositoryDelete(t *testing.T) {
121+ ctx, repo := newFaviconRepo(t)
122+ now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
123+ require.NoError(t, repo.Upsert(ctx, favicon.Metadata{Key: favicon.Key("example.com"), Source: favicon.SourceEngine, ContentHash: "hash", UpdatedAt: now, LastCheckedAt: now}))
124+
125+ require.NoError(t, repo.Delete(ctx, favicon.Key("example.com")))
126+
127+ got, err := repo.Get(ctx, favicon.Key("example.com"))
128+ require.NoError(t, err)
129+ assert.Nil(t, got)
130+}
A internal/infrastructure/persistence/sqlite/migrations/009_favicons.sql +16 -0
@@ -0,0 +1,16 @@
1+-- +goose Up
2+-- Store favicon cache metadata. Binary favicon data remains on disk.
3+
4+CREATE TABLE IF NOT EXISTS favicons (
5+ key TEXT PRIMARY KEY,
6+ source_url TEXT,
7+ page_url TEXT,
8+ source TEXT NOT NULL,
9+ content_hash TEXT NOT NULL,
10+ content_type TEXT,
11+ updated_at TIMESTAMP NOT NULL,
12+ last_checked_at TIMESTAMP NOT NULL
13+);
14+
15+-- +goose Down
16+DROP TABLE IF EXISTS favicons;
A internal/infrastructure/persistence/sqlite/queries/favicon.sql +31 -0
@@ -0,0 +1,31 @@
1+-- name: GetFavicon :one
2+SELECT * FROM favicons WHERE key = ? LIMIT 1;
3+
4+-- name: UpsertFavicon :exec
5+INSERT INTO favicons (
6+ key,
7+ source_url,
8+ page_url,
9+ source,
10+ content_hash,
11+ content_type,
12+ updated_at,
13+ last_checked_at
14+)
15+VALUES (?, ?, ?, ?, ?, ?, ?, ?)
16+ON CONFLICT(key) DO UPDATE SET
17+ source_url = EXCLUDED.source_url,
18+ page_url = EXCLUDED.page_url,
19+ source = EXCLUDED.source,
20+ content_hash = EXCLUDED.content_hash,
21+ content_type = EXCLUDED.content_type,
22+ updated_at = EXCLUDED.updated_at,
23+ last_checked_at = EXCLUDED.last_checked_at;
24+
25+-- name: UpdateFaviconLastChecked :exec
26+UPDATE favicons
27+SET content_hash = ?, last_checked_at = ?
28+WHERE key = ?;
29+
30+-- name: DeleteFavicon :exec
31+DELETE FROM favicons WHERE key = ?;
A internal/infrastructure/persistence/sqlite/sqlc/favicon.sql.go +105 -0
@@ -0,0 +1,105 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: favicon.sql
5+
6+package sqlc
7+
8+import (
9+ "context"
10+ "database/sql"
11+ "time"
12+)
13+
14+const DeleteFavicon = `-- name: DeleteFavicon :exec
15+DELETE FROM favicons WHERE key = ?
16+`
17+
18+func (q *Queries) DeleteFavicon(ctx context.Context, key string) error {
19+ _, err := q.db.ExecContext(ctx, DeleteFavicon, key)
20+ return err
21+}
22+
23+const GetFavicon = `-- name: GetFavicon :one
24+SELECT "key", source_url, page_url, source, content_hash, content_type, updated_at, last_checked_at FROM favicons WHERE key = ? LIMIT 1
25+`
26+
27+func (q *Queries) GetFavicon(ctx context.Context, key string) (Favicon, error) {
28+ row := q.db.QueryRowContext(ctx, GetFavicon, key)
29+ var i Favicon
30+ err := row.Scan(
31+ &i.Key,
32+ &i.SourceUrl,
33+ &i.PageUrl,
34+ &i.Source,
35+ &i.ContentHash,
36+ &i.ContentType,
37+ &i.UpdatedAt,
38+ &i.LastCheckedAt,
39+ )
40+ return i, err
41+}
42+
43+const UpdateFaviconLastChecked = `-- name: UpdateFaviconLastChecked :exec
44+UPDATE favicons
45+SET content_hash = ?, last_checked_at = ?
46+WHERE key = ?
47+`
48+
49+type UpdateFaviconLastCheckedParams struct {
50+ ContentHash string `json:"content_hash"`
51+ LastCheckedAt time.Time `json:"last_checked_at"`
52+ Key string `json:"key"`
53+}
54+
55+func (q *Queries) UpdateFaviconLastChecked(ctx context.Context, arg UpdateFaviconLastCheckedParams) error {
56+ _, err := q.db.ExecContext(ctx, UpdateFaviconLastChecked, arg.ContentHash, arg.LastCheckedAt, arg.Key)
57+ return err
58+}
59+
60+const UpsertFavicon = `-- name: UpsertFavicon :exec
61+INSERT INTO favicons (
62+ key,
63+ source_url,
64+ page_url,
65+ source,
66+ content_hash,
67+ content_type,
68+ updated_at,
69+ last_checked_at
70+)
71+VALUES (?, ?, ?, ?, ?, ?, ?, ?)
72+ON CONFLICT(key) DO UPDATE SET
73+ source_url = EXCLUDED.source_url,
74+ page_url = EXCLUDED.page_url,
75+ source = EXCLUDED.source,
76+ content_hash = EXCLUDED.content_hash,
77+ content_type = EXCLUDED.content_type,
78+ updated_at = EXCLUDED.updated_at,
79+ last_checked_at = EXCLUDED.last_checked_at
80+`
81+
82+type UpsertFaviconParams struct {
83+ Key string `json:"key"`
84+ SourceUrl sql.NullString `json:"source_url"`
85+ PageUrl sql.NullString `json:"page_url"`
86+ Source string `json:"source"`
87+ ContentHash string `json:"content_hash"`
88+ ContentType sql.NullString `json:"content_type"`
89+ UpdatedAt time.Time `json:"updated_at"`
90+ LastCheckedAt time.Time `json:"last_checked_at"`
91+}
92+
93+func (q *Queries) UpsertFavicon(ctx context.Context, arg UpsertFaviconParams) error {
94+ _, err := q.db.ExecContext(ctx, UpsertFavicon,
95+ arg.Key,
96+ arg.SourceUrl,
97+ arg.PageUrl,
98+ arg.Source,
99+ arg.ContentHash,
100+ arg.ContentType,
101+ arg.UpdatedAt,
102+ arg.LastCheckedAt,
103+ )
104+ return err
105+}
M internal/infrastructure/persistence/sqlite/sqlc/models.go +11 -0
@@ -15,6 +15,17 @@ type ContentWhitelist struct {
1515 CreatedAt sql.NullTime `json:"created_at"`
1616 }
1717
18+type Favicon struct {
19+ Key string `json:"key"`
20+ SourceUrl sql.NullString `json:"source_url"`
21+ PageUrl sql.NullString `json:"page_url"`
22+ Source string `json:"source"`
23+ ContentHash string `json:"content_hash"`
24+ ContentType sql.NullString `json:"content_type"`
25+ UpdatedAt time.Time `json:"updated_at"`
26+ LastCheckedAt time.Time `json:"last_checked_at"`
27+}
28+
1829 type Favorite struct {
1930 ID int64 `json:"id"`
2031 Url string `json:"url"`
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +4 -0
@@ -19,6 +19,7 @@ type Querier interface {
1919 DeleteAllHistory(ctx context.Context) error
2020 // Deletes exited browser sessions older than the given cutoff time.
2121 DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error)
22+ DeleteFavicon(ctx context.Context, key string) error
2223 DeleteFavorite(ctx context.Context, id int64) error
2324 DeleteFolder(ctx context.Context, id int64) error
2425 DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error
@@ -44,6 +45,7 @@ type Querier interface {
4445 GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)
4546 GetDailyVisitCount(ctx context.Context, date interface{}) ([]GetDailyVisitCountRow, error)
4647 GetDomainStats(ctx context.Context, limit int64) ([]GetDomainStatsRow, error)
48+ GetFavicon(ctx context.Context, key string) (Favicon, error)
4749 GetFavoriteByID(ctx context.Context, id int64) (Favorite, error)
4850 GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error)
4951 GetFavoriteByURL(ctx context.Context, url string) (Favorite, error)
@@ -90,11 +92,13 @@ type Querier interface {
9092 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
9193 SetPermission(ctx context.Context, arg SetPermissionParams) error
9294 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error
95+ UpdateFaviconLastChecked(ctx context.Context, arg UpdateFaviconLastCheckedParams) error
9396 UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error
9497 UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error
9598 UpdateFolder(ctx context.Context, arg UpdateFolderParams) error
9699 UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error
97100 UpdateTag(ctx context.Context, arg UpdateTagParams) error
101+ UpsertFavicon(ctx context.Context, arg UpsertFaviconParams) error
98102 UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error
99103 UpsertSessionState(ctx context.Context, arg UpsertSessionStateParams) error
100104 }
M internal/infrastructure/webkit/favicon_systemviews.go +5 -5
@@ -2,13 +2,13 @@ package webkit
22
33 import "github.com/bnema/dumber/internal/application/port"
44
5-var _ port.SystemviewFaviconServiceSetter = (*Engine)(nil)
5+var _ port.SystemviewFaviconResolverSetter = (*Engine)(nil)
66
7-// SetSystemviewFaviconService exposes cached favicons to systemview pages via
8-// dumb://api/favicon without triggering remote favicon fetches during History rendering.
9-func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) {
7+// SetSystemviewFaviconResolver exposes the application favicon resolver to
8+// systemview pages via dumb://api/favicon without disk-service access.
9+func (e *Engine) SetSystemviewFaviconResolver(resolver port.FaviconSystemviewResolver) {
1010 if e == nil || e.schemeHandler == nil {
1111 return
1212 }
13- e.schemeHandler.SetFaviconService(service)
13+ e.schemeHandler.SetFaviconResolver(resolver)
1414 }
M internal/infrastructure/webkit/scheme_handler.go +13 -18
@@ -9,7 +9,6 @@ import (
99 "io/fs"
1010 "net/http"
1111 "net/url"
12- "os"
1312 "path"
1413 "strconv"
1514 "strings"
@@ -17,7 +16,6 @@ import (
1716
1817 "github.com/andybalholm/brotli"
1918 "github.com/bnema/dumber/internal/application/port"
20- domainurl "github.com/bnema/dumber/internal/domain/url"
2119 "github.com/bnema/dumber/internal/infrastructure/webutil"
2220 "github.com/bnema/dumber/internal/logging"
2321 "github.com/bnema/puregotk/v4/gio"
@@ -75,7 +73,7 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
7573 type DumbSchemeHandler struct {
7674 handlers map[string]PageHandler
7775 assets embed.FS
78- faviconService port.FaviconService
76+ faviconResolver port.FaviconSystemviewResolver
7977 assetDir string // default subdirectory within embed.FS (e.g., "systemviews")
8078 logger zerolog.Logger
8179 mu sync.RWMutex
@@ -116,10 +114,10 @@ func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload fun
116114 h.logger.Debug().Msg("config payload builders configured")
117115 }
118116
119-func (h *DumbSchemeHandler) SetFaviconService(service port.FaviconService) {
117+func (h *DumbSchemeHandler) SetFaviconResolver(resolver port.FaviconSystemviewResolver) {
120118 h.mu.Lock()
121119 defer h.mu.Unlock()
122- h.faviconService = service
120+ h.faviconResolver = resolver
123121 }
124122
125123 // registerDefaults sets up default page handlers.
@@ -210,9 +208,9 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
210208 return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
211209 }
212210 h.mu.RLock()
213- service := h.faviconService
211+ resolver := h.faviconResolver
214212 h.mu.RUnlock()
215- if service == nil {
213+ if resolver == nil {
216214 return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable")
217215 }
218216 if req == nil {
@@ -222,7 +220,7 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
222220 if err != nil {
223221 return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL")
224222 }
225- domain := domainurl.CanonicalDomain(parsed.Query().Get("domain"))
223+ domain := strings.TrimSpace(parsed.Query().Get("domain"))
226224 if domain == "" {
227225 return privateJSONErrorResponse(http.StatusBadRequest, "missing domain")
228226 }
@@ -235,20 +233,17 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
235233 size = parsedSize
236234 }
237235
238- if !service.HasPNGSizedOnDisk(domain, size) {
236+ resolved, err := resolver.ResolveSystemviewIcon(context.Background(), domain, size)
237+ if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
239238 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
240239 }
241- diskPath := service.DiskPathPNGSized(domain, size)
242- if diskPath == "" {
243- return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
244- }
245- data, err := os.ReadFile(diskPath)
246- if err != nil || len(data) == 0 {
247- return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
240+ contentType := resolved.ContentType
241+ if contentType == "" {
242+ contentType = "image/png"
248243 }
249244 return &SchemeResponse{
250- Data: data,
251- ContentType: "image/png",
245+ Data: resolved.Bytes,
246+ ContentType: contentType,
252247 StatusCode: http.StatusOK,
253248 Headers: map[string]string{"Cache-Control": "no-store"},
254249 SuppressDefaultHeaders: true,
M internal/ui/adapter/favicon.go +161 -28
@@ -8,6 +8,7 @@ import (
88 "time"
99
1010 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/favicon"
1112 domainurl "github.com/bnema/dumber/internal/domain/url"
1213 "github.com/bnema/dumber/internal/logging"
1314 "github.com/bnema/puregotk/v4/gdk"
@@ -19,6 +20,7 @@ import (
1920 // gdk.Texture conversion and WebKit FaviconDatabase integration.
2021 type FaviconAdapter struct {
2122 service port.FaviconService
23+ resolver port.FaviconResolver
2224 faviconDB port.FaviconDatabase
2325 textureCache map[string]*gdk.Texture
2426 mu sync.RWMutex
@@ -57,9 +59,19 @@ type FaviconAdapterConfig struct {
5759 // NewFaviconAdapter creates a new FaviconAdapter.
5860 // The faviconDB can be nil if a favicon database is not available.
5961 func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {
62+ return NewFaviconAdapterWithResolver(service, nil, faviconDB, cfg)
63+}
64+
65+func NewFaviconAdapterWithResolver(
66+ service port.FaviconService,
67+ resolver port.FaviconResolver,
68+ faviconDB port.FaviconDatabase,
69+ cfg FaviconAdapterConfig,
70+) *FaviconAdapter {
6071 disabled := os.Getenv(disableFaviconsEnv) == "1"
6172 return &FaviconAdapter{
6273 service: service,
74+ resolver: resolver,
6375 faviconDB: faviconDB,
6476 textureCache: make(map[string]*gdk.Texture),
6577 warnCounts: make(map[string]int),
@@ -132,26 +144,34 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
132144 return
133145 }
134146
147+ // Check application resolver before falling back to the legacy service.
148+ if texture := a.resolveTexture(ctx, pageURL, true); texture != nil {
149+ callback(texture)
150+ return
151+ }
152+
135153 // Check service cache (memory + disk) and convert to texture
136- if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
137- log.Debug().
138- Str("domain", domain).
139- Int("bytes", len(data)).
140- Msg("favicon: service cache hit")
141- texture := a.textureFromBytesOnGTK(ctx, data)
142- if texture != nil {
143- a.setTexture(domain, texture)
144- // Ensure sized PNG exists for CLI tools (async, idempotent)
145- go a.ensureSizedPNG(ctx, domain)
146- callback(texture)
147- return
154+ if a.service != nil {
155+ if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
156+ log.Debug().
157+ Str("domain", domain).
158+ Int("bytes", len(data)).
159+ Msg("favicon: service cache hit")
160+ texture := a.textureFromBytesOnGTK(ctx, data)
161+ if texture != nil {
162+ a.setTexture(domain, texture)
163+ // Ensure sized PNG exists for CLI tools (async, idempotent)
164+ go a.ensureSizedPNG(ctx, domain)
165+ callback(texture)
166+ return
167+ }
148168 }
149169 }
150170
151171 // Try engine FaviconDatabase if available
152172 if a.faviconDB != nil {
153173 a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {
154- a.handleEngineFaviconDBTexture(ctx, domain, callback, tex)
174+ a.handleEngineFaviconDBTexture(ctx, pageURL, domain, callback, tex)
155175 })
156176 return
157177 }
@@ -162,6 +182,9 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
162182 }
163183
164184 func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string, callback func(*gdk.Texture), texture *gdk.Texture) {
185+ if callback == nil {
186+ return
187+ }
165188 log := logging.FromContext(ctx)
166189 log.Debug().
167190 Str("domain", domain).
@@ -178,7 +201,49 @@ func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string,
178201 glib.IdleAdd(&cb, 0)
179202 }
180203
204+func (a *FaviconAdapter) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string, callback func(*gdk.Texture)) {
205+ domain := domainurl.ExtractDomain(pageURL)
206+ if a.resolver == nil {
207+ a.invokeCallbackOnGTK(ctx, domain, callback, nil)
208+ return
209+ }
210+ go func() {
211+ if err := a.resolver.RefreshFromIconURLs(ctx, pageURL, iconURLs); err != nil {
212+ logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: RefreshFromIconURLs failed")
213+ }
214+ if callback == nil {
215+ return
216+ }
217+ a.GetOrFetch(ctx, pageURL, func(texture *gdk.Texture) {
218+ a.invokeCallbackOnGTK(ctx, domain, callback, texture)
219+ })
220+ }()
221+}
222+
223+func (a *FaviconAdapter) resolveTexture(ctx context.Context, pageURL string, scheduleRefresh bool) *gdk.Texture {
224+ if a.resolver == nil {
225+ return nil
226+ }
227+ options := port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI}
228+ if scheduleRefresh {
229+ options.ScheduleBackgroundRefresh = true
230+ }
231+ resolved, err := a.resolver.Resolve(ctx, pageURL, 0, options)
232+ if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
233+ return nil
234+ }
235+ texture := a.textureFromBytesOnGTK(ctx, resolved.Bytes)
236+ if texture != nil {
237+ a.setTexture(string(resolved.Key), texture)
238+ }
239+ return texture
240+}
241+
181242 func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) {
243+ if a.service == nil {
244+ a.invokeCallbackOnGTK(ctx, domain, callback, nil)
245+ return
246+ }
182247 go func() {
183248 log := logging.FromContext(ctx)
184249 log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin")
@@ -209,7 +274,12 @@ func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, cal
209274 }()
210275 }
211276
212-func (a *FaviconAdapter) handleEngineFaviconDBTexture(ctx context.Context, domain string, callback func(*gdk.Texture), tex port.Texture) {
277+func (a *FaviconAdapter) handleEngineFaviconDBTexture(
278+ ctx context.Context,
279+ pageURL, domain string,
280+ callback func(*gdk.Texture),
281+ tex port.Texture,
282+) {
213283 log := logging.FromContext(ctx)
214284 log.Debug().
215285 Str("domain", domain).
@@ -228,7 +298,7 @@ func (a *FaviconAdapter) handleEngineFaviconDBTexture(ctx context.Context, domai
228298 }
229299 a.setTexture(domain, gdkTex)
230300 a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex)
231- a.saveFaviconToDisk(ctx, domain, gdkTex)
301+ a.storeTextureObservation(ctx, pageURL, gdkTex)
232302 }
233303
234304 // StoreFromWebKit stores a favicon texture received from WebKit signals.
@@ -252,7 +322,12 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te
252322 // Store in texture cache
253323 a.setTexture(domain, texture)
254324
255- // Export as PNG and ensure disk cache
325+ if a.resolver != nil {
326+ a.storeTextureObservation(ctx, pageURL, texture)
327+ return
328+ }
329+
330+ // Legacy fallback for callers not yet wired to the application resolver.
256331 a.saveFaviconToDisk(ctx, domain, texture)
257332 }
258333
@@ -269,14 +344,55 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
269344 currentDomain := domainurl.ExtractDomain(currentURL)
270345 if originDomain != "" && originDomain != currentDomain {
271346 a.setTexture(originDomain, texture)
272- a.saveFaviconToDisk(ctx, originDomain, texture)
347+ if a.resolver != nil {
348+ a.storeTextureObservation(ctx, originURL, texture)
349+ } else {
350+ a.saveFaviconToDisk(ctx, originDomain, texture)
351+ }
273352 }
274353 }
275354 }
276355
356+func (a *FaviconAdapter) storeTextureObservation(ctx context.Context, pageURL string, texture *gdk.Texture) {
357+ if a.resolver == nil || texture == nil || pageURL == "" {
358+ return
359+ }
360+ cb := glib.SourceFunc(func(_ uintptr) bool {
361+ tmp, err := os.CreateTemp("", "dumber-favicon-*.png")
362+ if err != nil {
363+ logging.FromContext(ctx).Debug().Err(err).Msg("favicon: failed to create temp PNG for observation")
364+ return false
365+ }
366+ path := tmp.Name()
367+ _ = tmp.Close()
368+ defer func() { _ = os.Remove(path) }()
369+ if ok := texture.SaveToPng(path); !ok {
370+ logging.FromContext(ctx).Debug().Str("page_url", pageURL).Msg("favicon: failed to export texture for observation")
371+ return false
372+ }
373+ data, err := os.ReadFile(path)
374+ if err != nil || len(data) == 0 {
375+ logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: failed to read exported observation")
376+ return false
377+ }
378+ go func() {
379+ // Engine-provided textures do not expose the original icon URL, so pageURL
380+ // is passed as the observation iconURL fallback for SourceEngine records.
381+ if _, err := a.resolver.Observe(ctx, pageURL, pageURL, data, favicon.SourceEngine, "image/png"); err != nil {
382+ logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: failed to observe engine texture")
383+ }
384+ }()
385+ return false
386+ })
387+ glib.IdleAdd(&cb, 0)
388+}
389+
277390 // saveFaviconToDisk exports a favicon texture to PNG and ensures disk cache is populated.
278391 // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.
279392 func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {
393+ if a.service == nil {
394+ return
395+ }
280396 log := logging.FromContext(ctx)
281397 cacheDirWarnKey := "cache-dir"
282398 savePNGWarnKey := "save-png:" + domain
@@ -359,16 +475,23 @@ func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *
359475 return texture
360476 }
361477
478+ // Check application resolver before falling back to the legacy service.
479+ if texture := a.resolveTexture(ctx, pageURL, false); texture != nil {
480+ return texture
481+ }
482+
362483 // Check service cache (memory + disk)
363- if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
364- log.Debug().
365- Str("domain", domain).
366- Int("bytes", len(data)).
367- Msg("favicon: PreloadFromCache service hit")
368- texture := a.textureFromBytesOnGTK(ctx, data)
369- if texture != nil {
370- a.setTexture(domain, texture)
371- return texture
484+ if a.service != nil {
485+ if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
486+ log.Debug().
487+ Str("domain", domain).
488+ Int("bytes", len(data)).
489+ Msg("favicon: PreloadFromCache service hit")
490+ texture := a.textureFromBytesOnGTK(ctx, data)
491+ if texture != nil {
492+ a.setTexture(domain, texture)
493+ return texture
494+ }
372495 }
373496 }
374497
@@ -384,7 +507,17 @@ func (a *FaviconAdapter) Service() port.FaviconService {
384507
385508 // Close shuts down the adapter and underlying service.
386509 func (a *FaviconAdapter) Close() {
387- a.service.Close()
510+ if a.service != nil {
511+ a.service.Close()
512+ }
513+}
514+
515+// Invalidate clears one resolved-key texture cache entry.
516+func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {
517+ a.mu.Lock()
518+ delete(a.textureCache, string(key))
519+ a.mu.Unlock()
520+ return nil
388521 }
389522
390523 // Clear removes all entries from the texture cache.
@@ -415,7 +548,7 @@ func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
415548 // ensureSizedPNG ensures the sized PNG exists for CLI tools.
416549 // This is idempotent - it only creates the file if it doesn't exist.
417550 func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
418- if domain == "" {
551+ if domain == "" || a.service == nil {
419552 return
420553 }
421554
M internal/ui/app.go +36 -12
@@ -97,10 +97,10 @@ type App struct {
9797 kbDispatcher *dispatcher.KeyboardDispatcher
9898
9999 // Pane management (used by coordinators)
100- panesUC *usecase.ManagePanesUseCase
101- workspaceViews map[entity.TabID]*component.WorkspaceView
102- windowForTab map[entity.TabID]*browserWindow
103- widgetFactory layout.WidgetFactory
100+ panesUC *usecase.ManagePanesUseCase
101+ workspaceViews map[entity.TabID]*component.WorkspaceView
102+ windowForTab map[entity.TabID]*browserWindow
103+ widgetFactory layout.WidgetFactory
104104 workspaceViewCreateOverride func(context.Context, *entity.Tab) bool
105105 stackedPaneMgr *component.StackedPaneManager
106106
@@ -220,8 +220,14 @@ func New(deps *Dependencies) (*App, error) {
220220 glib.IdleAdd(&cb, 0)
221221 })
222222 }
223- if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconServiceSetter); ok {
224- faviconSetter.SetSystemviewFaviconService(deps.FaviconService)
223+ if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconResolverSetter); ok {
224+ resolver := deps.SystemviewFaviconResolver
225+ if resolver == nil {
226+ resolver = deps.FaviconResolver
227+ }
228+ if resolver != nil {
229+ faviconSetter.SetSystemviewFaviconResolver(resolver)
230+ }
225231 }
226232
227233 // Register message handlers through the engine.
@@ -1344,14 +1350,23 @@ func buildOmniboxConfig(
13441350 }
13451351 }
13461352
1353+func registerFaviconInvalidator(resolver port.FaviconResolver, invalidator port.FaviconInvalidator) {
1354+ registry, ok := resolver.(port.FaviconInvalidatorRegistry)
1355+ if !ok || invalidator == nil {
1356+ return
1357+ }
1358+ registry.RegisterFaviconInvalidator(invalidator)
1359+}
1360+
13471361 func NewStandaloneOmniboxRuntime(
13481362 ctx context.Context,
13491363 deps *Dependencies,
13501364 faviconDB port.FaviconDatabase,
13511365 ) *StandaloneOmniboxRuntime {
13521366 var faviconAdapter *adapter.FaviconAdapter
1353- if deps != nil && deps.FaviconService != nil {
1354- faviconAdapter = adapter.NewFaviconAdapter(deps.FaviconService, faviconDB, deps.FaviconAdapterConfig)
1367+ if deps != nil && (deps.FaviconService != nil || deps.FaviconResolver != nil) {
1368+ faviconAdapter = adapter.NewFaviconAdapterWithResolver(deps.FaviconService, deps.FaviconResolver, faviconDB, deps.FaviconAdapterConfig)
1369+ registerFaviconInvalidator(deps.FaviconResolver, faviconAdapter)
13551370 }
13561371
13571372 omniboxCfg := buildOmniboxConfig(deps, faviconAdapter, omniboxCallbacks{
@@ -3136,10 +3151,19 @@ func (a *App) initCoordinators(ctx context.Context) {
31363151 return a.activeWorkspace(), a.activeWorkspaceView()
31373152 }
31383153
3139- // Create FaviconAdapter with service and engine FaviconDatabase.
3140- // Skip if FaviconService is nil (e.g. in tests or when favicon support is disabled).
3141- if a.deps.FaviconService != nil {
3142- a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, a.engine.FaviconDatabase(), a.deps.FaviconAdapterConfig)
3154+ // Create FaviconAdapter with resolver/service and engine FaviconDatabase.
3155+ // Skip if favicon support is not wired (e.g. in tests or when disabled).
3156+ if a.deps.FaviconService != nil || a.deps.FaviconResolver != nil {
3157+ a.faviconAdapter = adapter.NewFaviconAdapterWithResolver(
3158+ a.deps.FaviconService,
3159+ a.deps.FaviconResolver,
3160+ a.engine.FaviconDatabase(),
3161+ a.deps.FaviconAdapterConfig,
3162+ )
3163+ registerFaviconInvalidator(a.deps.FaviconResolver, a.faviconAdapter)
3164+ if invalidator, ok := a.engine.(port.FaviconInvalidator); ok {
3165+ registerFaviconInvalidator(a.deps.FaviconResolver, invalidator)
3166+ }
31433167 }
31443168
31453169 // 1. Content Coordinator (no dependencies on other coordinators)
M internal/ui/coordinator/content/callbacks.go +1 -11
@@ -9,7 +9,6 @@ import (
99 "github.com/bnema/dumber/internal/application/usecase"
1010 "github.com/bnema/dumber/internal/domain/entity"
1111 "github.com/bnema/dumber/internal/logging"
12- "github.com/bnema/puregotk/v4/gdk"
1312 )
1413
1514 // internalSchemePath is the host used in dumb:// crash-page URIs.
@@ -122,16 +121,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
122121 },
123122 }
124123
125- // Favicon changes - use Generation() from port.WebView to detect stale callbacks
126- faviconGen := wv.Generation()
127- callbacks.OnFaviconChanged = func(favicon port.Texture) {
128- if wv.Generation() != faviconGen {
129- return // WebView was reused, ignore stale signal
130- }
131- if gdkTexture, ok := favicon.(*gdk.Texture); ok {
132- c.onFaviconChanged(ctx, paneID, wv, gdkTexture)
133- }
134- }
124+ c.setupFaviconCallbacks(ctx, paneID, wv, callbacks)
135125
136126 // Middle-click link handler
137127 callbacks.OnLinkMiddleClick = func(uri string) bool {
A internal/ui/coordinator/content/favicon_callbacks.go +45 -0
@@ -0,0 +1,45 @@
1+package content
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/puregotk/v4/gdk"
10+)
11+
12+func (c *Coordinator) setupFaviconCallbacks(
13+ ctx context.Context,
14+ paneID entity.PaneID,
15+ wv port.WebView,
16+ callbacks *port.WebViewCallbacks,
17+) {
18+ faviconGen := wv.Generation()
19+ callbacks.OnFaviconChanged = func(favicon port.Texture) {
20+ if wv.Generation() != faviconGen {
21+ return
22+ }
23+ if gdkTexture, ok := favicon.(*gdk.Texture); ok {
24+ c.onFaviconChanged(ctx, paneID, wv, gdkTexture)
25+ }
26+ }
27+ callbacks.OnFaviconURLChanged = func(pageURL string, iconURLs []string) {
28+ if wv.Generation() != faviconGen || c.faviconAdapter == nil || pageURL == "" || len(iconURLs) == 0 {
29+ return
30+ }
31+ c.faviconAdapter.RefreshFromIconURLs(ctx, pageURL, iconURLs, func(texture *gdk.Texture) {
32+ if texture == nil || wv.Generation() != faviconGen {
33+ return
34+ }
35+ currentWV := c.getWebViewLocked(paneID)
36+ if currentWV != wv {
37+ return
38+ }
39+ if strings.TrimSpace(currentWV.URI()) != strings.TrimSpace(pageURL) {
40+ return
41+ }
42+ c.updateStackedFaviconForPane(ctx, paneID, texture)
43+ })
44+ }
45+}
M internal/ui/deps.go +7 -5
@@ -59,11 +59,13 @@ type Dependencies struct {
5959 CopyURLUC *usecase.CopyURLUseCase
6060
6161 // Infrastructure Adapters
62- Clipboard port.Clipboard
63- FaviconService port.FaviconService
64- FaviconAdapterConfig adapter.FaviconAdapterConfig
65- FilterManager port.FilterManager
66- IdleInhibitor port.IdleInhibitor
62+ Clipboard port.Clipboard
63+ FaviconService port.FaviconService
64+ FaviconResolver port.FaviconResolver
65+ SystemviewFaviconResolver port.FaviconSystemviewResolver
66+ FaviconAdapterConfig adapter.FaviconAdapterConfig
67+ FilterManager port.FilterManager
68+ IdleInhibitor port.IdleInhibitor
6769
6870 // Accent picker for dead keys support
6971 InsertAccentUC *usecase.InsertAccentUseCase

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