detached 1b29d2c

Merge pull request #276 from bnema/feature/favicon-cache-refresh-resolution

feat(favicon): refresh cache and resolve favicons
commit
1b29d2c1232c291c7d29ac5c3e404d7bf76795ba
author
bnema · · 2026-06-07 20:12 UTC
committer
GitHub · · 2026-06-07 20:12 UTC
merge of
5a2fbe6 + 4ac613d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
52 files · +3838 -363
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
@@ -155,8 +155,8 @@ golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
155155 golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
156156 golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
157157 golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
158-golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
159-golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
158+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
159+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
160160 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
161161 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
162162 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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 +441 -0
@@ -0,0 +1,441 @@
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+ repaired, repairErr := uc.repairOrphanBlobMetadata(ctx, key, rawURLOrDomain, size)
151+ if repairErr != nil {
152+ if !errors.Is(repairErr, ErrFaviconMiss) {
153+ return nil, repairErr
154+ }
155+ continue
156+ }
157+ return repaired, nil
158+ }
159+ bytes, contentType, err := uc.readForSize(ctx, key, size)
160+ if err != nil {
161+ if !errors.Is(err, ErrFaviconMiss) {
162+ return nil, err
163+ }
164+ continue
165+ }
166+ res := &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: meta}
167+ if favicon.ShouldRefresh(meta, uc.now(), uc.ttl) && options.ScheduleBackgroundRefresh {
168+ res.BackgroundRefreshScheduled = uc.scheduleRefresh(key, meta.PageURL)
169+ }
170+ return res, nil
171+ }
172+
173+ if options.AllowBlockingRefresh {
174+ if err := uc.refresh(ctx, rawURLOrDomain, true); err != nil {
175+ return nil, err
176+ }
177+ return uc.Resolve(ctx, rawURLOrDomain, size, ResolveOptions{Purpose: options.Purpose})
178+ }
179+ if options.ScheduleBackgroundRefresh {
180+ uc.scheduleRefresh(keys[0], rawURLOrDomain)
181+ }
182+ return nil, ErrFaviconMiss
183+}
184+
185+func (uc *FaviconUseCase) ResolveSystemviewIcon(ctx context.Context, rawDomain string, size int) (*appport.ResolvedFavicon, error) {
186+ if size != SystemviewIconSize {
187+ return nil, ErrFaviconMiss
188+ }
189+ return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview})
190+}
191+
192+func (uc *FaviconUseCase) repairOrphanBlobMetadata(
193+ ctx context.Context,
194+ key favicon.Key,
195+ rawURLOrDomain string,
196+ size int,
197+) (*appport.ResolvedFavicon, error) {
198+ bytes, contentType, err := uc.readForSize(ctx, key, size)
199+ if err != nil {
200+ return nil, err
201+ }
202+ if contentType == "" {
203+ contentType = "image/png"
204+ }
205+ now := uc.now()
206+ meta := favicon.Metadata{
207+ Key: key,
208+ SourceURL: rawURLOrDomain,
209+ PageURL: rawURLOrDomain,
210+ Source: favicon.SourceImported,
211+ ContentHash: favicon.Hash(bytes),
212+ ContentType: contentType,
213+ UpdatedAt: now,
214+ LastCheckedAt: now,
215+ }
216+ if err := uc.repo.Upsert(ctx, meta); err != nil {
217+ return nil, err
218+ }
219+ return &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: &meta}, nil
220+}
221+
222+func (uc *FaviconUseCase) Observe(
223+ ctx context.Context,
224+ pageURL, iconURL string,
225+ bytes []byte,
226+ source favicon.Source,
227+ contentType string,
228+) (*favicon.Metadata, error) {
229+ key, ok := favicon.CanonicalKey(pageURL)
230+ if !ok || len(bytes) == 0 {
231+ return nil, ErrFaviconMiss
232+ }
233+ old, err := uc.repo.Get(ctx, key)
234+ if err != nil {
235+ return nil, err
236+ }
237+ now := uc.now()
238+ if !favicon.HasContentChanged(old, bytes) {
239+ if err := uc.repo.UpdateLastChecked(ctx, key, old.ContentHash, now); err != nil {
240+ return nil, err
241+ }
242+ old.LastCheckedAt = now
243+ return old, nil
244+ }
245+
246+ var converted *appport.ConvertedFavicon
247+ if uc.converter != nil {
248+ var err error
249+ converted, err = uc.converter.Convert(ctx, bytes, contentType, []int{SystemviewIconSize})
250+ if err != nil {
251+ return nil, err
252+ }
253+ }
254+
255+ if err := uc.blobs.WriteOriginal(ctx, key, bytes, contentType); err != nil {
256+ return nil, err
257+ }
258+ if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
259+ return nil, err
260+ }
261+ if converted != nil {
262+ if len(converted.PNG) > 0 {
263+ if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
264+ return nil, err
265+ }
266+ }
267+ if b := converted.SizedPNG[SystemviewIconSize]; len(b) > 0 {
268+ if err := uc.blobs.WriteSizedPNG(ctx, key, SystemviewIconSize, b); err != nil {
269+ return nil, err
270+ }
271+ }
272+ }
273+ meta := favicon.Metadata{
274+ Key: key,
275+ SourceURL: iconURL,
276+ PageURL: pageURL,
277+ Source: source,
278+ ContentHash: favicon.Hash(bytes),
279+ ContentType: contentType,
280+ UpdatedAt: now,
281+ LastCheckedAt: now,
282+ }
283+ if err := uc.repo.Upsert(ctx, meta); err != nil {
284+ return nil, err
285+ }
286+ if err := uc.invalidateCaches(ctx, key); err != nil {
287+ return nil, err
288+ }
289+ return &meta, nil
290+}
291+
292+func (uc *FaviconUseCase) RefreshIfStale(ctx context.Context, pageURL string) error {
293+ return uc.refresh(ctx, pageURL, false)
294+}
295+
296+func (uc *FaviconUseCase) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string) error {
297+ if uc.fetcher == nil {
298+ return ErrFaviconMiss
299+ }
300+ var lastMiss error
301+ for _, iconURL := range iconURLs {
302+ if err := ctx.Err(); err != nil {
303+ return err
304+ }
305+ fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL, IconURL: iconURL})
306+ if errors.Is(err, ErrFaviconMiss) {
307+ lastMiss = err
308+ continue
309+ }
310+ if err != nil {
311+ return err
312+ }
313+ _, err = uc.Observe(ctx, fetched.PageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
314+ if errors.Is(err, ErrFaviconMiss) {
315+ lastMiss = err
316+ continue
317+ }
318+ return err
319+ }
320+ if lastMiss != nil {
321+ return lastMiss
322+ }
323+ return uc.RefreshIfStale(ctx, pageURL)
324+}
325+
326+func (uc *FaviconUseCase) refresh(ctx context.Context, pageURL string, force bool) error {
327+ keys := favicon.Candidates(pageURL)
328+ if len(keys) == 0 {
329+ return ErrFaviconMiss
330+ }
331+ if !force {
332+ fresh, err := uc.anyCandidateFresh(ctx, keys)
333+ if err != nil {
334+ return err
335+ }
336+ if fresh {
337+ return nil
338+ }
339+ }
340+ return uc.fetchAndObserve(ctx, pageURL)
341+}
342+
343+func (uc *FaviconUseCase) refreshKey(ctx context.Context, key favicon.Key, pageURL string) error {
344+ meta, err := uc.repo.Get(ctx, key)
345+ if err != nil {
346+ return err
347+ }
348+ if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
349+ return nil
350+ }
351+ return uc.fetchAndObserve(ctx, pageURL)
352+}
353+
354+func (uc *FaviconUseCase) anyCandidateFresh(ctx context.Context, keys []favicon.Key) (bool, error) {
355+ for _, key := range keys {
356+ meta, err := uc.repo.Get(ctx, key)
357+ if err != nil {
358+ return false, err
359+ }
360+ if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
361+ return true, nil
362+ }
363+ }
364+ return false, nil
365+}
366+
367+func (uc *FaviconUseCase) fetchAndObserve(ctx context.Context, pageURL string) error {
368+ if uc.fetcher == nil {
369+ return ErrFaviconMiss
370+ }
371+ fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL})
372+ if err != nil {
373+ return err
374+ }
375+ _, err = uc.Observe(ctx, fetched.PageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
376+ return err
377+}
378+
379+func (uc *FaviconUseCase) EnsureSized(ctx context.Context, key favicon.Key, size int) error {
380+ if _, _, err := uc.blobs.ReadSizedPNG(ctx, key, size); err == nil {
381+ return nil
382+ } else if !errors.Is(err, ErrFaviconMiss) {
383+ return err
384+ }
385+ orig, ct, err := uc.blobs.ReadOriginal(ctx, key)
386+ if err != nil {
387+ return err
388+ }
389+ if uc.converter == nil {
390+ return ErrFaviconMiss
391+ }
392+ converted, err := uc.converter.Convert(ctx, orig, ct, []int{size})
393+ if err != nil {
394+ return err
395+ }
396+ if len(converted.PNG) > 0 {
397+ if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
398+ return err
399+ }
400+ }
401+ if b := converted.SizedPNG[size]; len(b) > 0 {
402+ return uc.blobs.WriteSizedPNG(ctx, key, size, b)
403+ }
404+ return ErrFaviconMiss
405+}
406+
407+func (uc *FaviconUseCase) Invalidate(ctx context.Context, key favicon.Key) error {
408+ if uc.blobs != nil {
409+ if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
410+ return err
411+ }
412+ }
413+ return uc.invalidateCaches(ctx, key)
414+}
415+
416+func (uc *FaviconUseCase) invalidateCaches(ctx context.Context, key favicon.Key) error {
417+ if uc.invalidators != nil {
418+ return uc.invalidators.InvalidateAll(ctx, key)
419+ }
420+ return nil
421+}
422+
423+func (uc *FaviconUseCase) readForSize(ctx context.Context, key favicon.Key, size int) ([]byte, string, error) {
424+ if size < 0 {
425+ return nil, "", fmt.Errorf("invalid favicon size: %d", size)
426+ }
427+ if size > 0 {
428+ if err := uc.EnsureSized(ctx, key, size); err != nil {
429+ return nil, "", err
430+ }
431+ return uc.blobs.ReadSizedPNG(ctx, key, size)
432+ }
433+ return uc.blobs.ReadPNG(ctx, key)
434+}
435+
436+func (uc *FaviconUseCase) scheduleRefresh(key favicon.Key, pageURL string) bool {
437+ if uc.scheduler == nil {
438+ return false
439+ }
440+ return uc.scheduler.Schedule(uc.background, key, func(ctx context.Context) { _ = uc.refreshKey(ctx, key, pageURL) })
441+}
A internal/application/usecase/favicon_test.go +532 -0
@@ -0,0 +1,532 @@
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 TestFaviconResolveRepairsOrphanCanonicalBlobMetadata(t *testing.T) {
38+ fx := newFaviconFixture()
39+ fx.blobs.png["example.com"] = []byte("png")
40+ fx.blobs.sized["example.com"] = map[int][]byte{SystemviewIconSize: []byte("png32")}
41+
42+ got, err := fx.uc.ResolveSystemviewIcon(context.Background(), "example.com", SystemviewIconSize)
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ if got.Key != "example.com" || string(got.Bytes) != "png32" {
47+ t.Fatalf("unexpected resolve: %#v %q", got, got.Bytes)
48+ }
49+ meta := fx.repo.byKey["example.com"]
50+ if meta == nil {
51+ t.Fatal("expected metadata to be repaired")
52+ }
53+ if meta.Source != favicon.SourceImported || meta.ContentType != "image/png" || meta.ContentHash != favicon.Hash([]byte("png32")) {
54+ t.Fatalf("unexpected repaired metadata: %#v", meta)
55+ }
56+}
57+
58+func TestFaviconResolveRepairOrphanWithoutConverterReturnsMiss(t *testing.T) {
59+ fx := newFaviconFixture()
60+ fx.uc.converter = nil
61+ fx.blobs.original["example.com"] = []byte("original")
62+ fx.blobs.contentTypes["example.com"] = "image/png"
63+
64+ _, err := fx.uc.ResolveSystemviewIcon(context.Background(), "example.com", SystemviewIconSize)
65+ if !errors.Is(err, ErrFaviconMiss) {
66+ t.Fatalf("expected favicon miss, got %v", err)
67+ }
68+}
69+
70+func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) {
71+ fx := newFaviconFixture()
72+ fx.seed("example.com", []byte("old"), true)
73+ for i := 0; i < 2; i++ {
74+ got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{ScheduleBackgroundRefresh: true})
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ if !got.BackgroundRefreshScheduled && i == 0 {
79+ t.Fatal("first stale resolve did not schedule refresh")
80+ }
81+ }
82+ if fx.scheduler.schedules != 1 {
83+ t.Fatalf("schedules=%d", fx.scheduler.schedules)
84+ }
85+ if fx.scheduler.usedCallerContext {
86+ t.Fatal("scheduler used caller context")
87+ }
88+}
89+
90+func TestFaviconResolveStaleExactRefreshIgnoresFreshParent(t *testing.T) {
91+ fx := newFaviconFixture()
92+ fx.seed("docs.example.com", []byte("old-exact"), true)
93+ fx.seed("example.com", []byte("fresh-parent"), false)
94+ got, err := fx.uc.Resolve(
95+ context.Background(),
96+ "https://docs.example.com/page",
97+ 32,
98+ ResolveOptions{ScheduleBackgroundRefresh: true},
99+ )
100+ if err != nil {
101+ t.Fatal(err)
102+ }
103+ if got.Key != "docs.example.com" || !got.BackgroundRefreshScheduled {
104+ t.Fatalf("unexpected resolve: %#v", got)
105+ }
106+ fx.scheduler.run("docs.example.com")
107+ if string(fx.blobs.original["docs.example.com"]) != "fetched" {
108+ t.Fatalf("exact key was not refreshed, originals=%v", fx.blobs.original)
109+ }
110+}
111+
112+func TestFaviconObserveSameHashUpdatesMetadataOnly(t *testing.T) {
113+ fx := newFaviconFixture()
114+ fx.seedOriginal([]byte("same"))
115+ if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://cdn.test/icon.png", []byte("same"), favicon.SourceEngine, "image/png"); err != nil {
116+ t.Fatal(err)
117+ }
118+ if fx.blobs.writeOriginals != 0 || fx.converter.calls != 0 || fx.invalidators.calls != 0 {
119+ t.Fatalf("rewrote/converted/invalidated same hash")
120+ }
121+ if fx.repo.byKey["example.com"].LastCheckedAt != fx.now {
122+ t.Fatal("last checked not updated")
123+ }
124+}
125+
126+func TestFaviconObserveChangedHashRegeneratesAndInvalidates(t *testing.T) {
127+ fx := newFaviconFixture()
128+ fx.seedOriginal([]byte("old"))
129+ if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://cdn.test/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); err != nil {
130+ t.Fatal(err)
131+ }
132+ if fx.blobs.writeOriginals != 1 || fx.converter.calls != 1 || fx.blobs.removeDerived != 1 || fx.invalidators.calls != 1 {
133+ t.Fatalf("writes=%d converts=%d remove=%d invalidates=%d", fx.blobs.writeOriginals, fx.converter.calls, fx.blobs.removeDerived, fx.invalidators.calls)
134+ }
135+ if string(fx.blobs.png["example.com"]) != "png:new" || string(fx.blobs.sized["example.com"][32]) != "png32:new" {
136+ t.Fatal("derived pngs not regenerated")
137+ }
138+ if fx.repo.byKey["example.com"].SourceURL != "https://cdn.test/icon.png" {
139+ t.Fatal("iconURL not stored as metadata")
140+ }
141+}
142+
143+func TestFaviconObserveWithoutConverterStoresOriginalOnly(t *testing.T) {
144+ fx := newFaviconFixture()
145+ fx.uc.converter = nil
146+ if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://example.com/icon.png", []byte("raw"), favicon.SourceEngine, "image/png"); err != nil {
147+ t.Fatal(err)
148+ }
149+ if string(fx.blobs.original["example.com"]) != "raw" {
150+ t.Fatalf("original not stored: %v", fx.blobs.original)
151+ }
152+ if len(fx.blobs.png) != 0 || len(fx.blobs.sized) != 0 {
153+ t.Fatalf("unexpected derivatives: png=%v sized=%v", fx.blobs.png, fx.blobs.sized)
154+ }
155+}
156+
157+func TestFaviconResolveMissingFetchesOnlyWhenBlockingAllowed(t *testing.T) {
158+ fx := newFaviconFixture()
159+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, ErrFaviconMiss) {
160+ t.Fatalf("default resolve err=%v", err)
161+ }
162+ if fx.fetcher.calls != 0 {
163+ t.Fatal("default resolve blocked on fetch")
164+ }
165+ got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true})
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+ if got.Key != "example.com" || fx.fetcher.calls != 1 {
170+ t.Fatalf("blocking resolve failed: %#v calls=%d", got, fx.fetcher.calls)
171+ }
172+}
173+
174+func TestFaviconRefreshIfStaleUsesParentFallbackBeforeFetching(t *testing.T) {
175+ fx := newFaviconFixture()
176+ fx.seed("example.com", []byte("parent"), false)
177+ if err := fx.uc.RefreshIfStale(context.Background(), "https://docs.example.com/page"); err != nil {
178+ t.Fatal(err)
179+ }
180+ if fx.fetcher.calls != 0 {
181+ t.Fatalf("fresh parent fallback should not fetch, calls=%d", fx.fetcher.calls)
182+ }
183+}
184+
185+func TestFaviconUseCasePropagatesRepositoryErrors(t *testing.T) {
186+ fx := newFaviconFixture()
187+ fx.repo.err = errors.New("database unavailable")
188+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, fx.repo.err) {
189+ t.Fatalf("Resolve err=%v, want %v", err, fx.repo.err)
190+ }
191+ 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) {
192+ t.Fatalf("Observe err=%v, want %v", err, fx.repo.err)
193+ }
194+ if err := fx.uc.RefreshIfStale(context.Background(), "https://example.com"); !errors.Is(err, fx.repo.err) {
195+ t.Fatalf("RefreshIfStale err=%v, want %v", err, fx.repo.err)
196+ }
197+}
198+
199+func TestFaviconResolvePropagatesBlobErrors(t *testing.T) {
200+ fx := newFaviconFixture()
201+ fx.seed("example.com", []byte("cached"), false)
202+ fx.blobs.err = errors.New("disk unavailable")
203+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, fx.blobs.err) {
204+ t.Fatalf("Resolve err=%v, want %v", err, fx.blobs.err)
205+ }
206+}
207+
208+func TestFaviconResolveBlockingRefreshesWhenMetadataFreshButBlobMissing(t *testing.T) {
209+ fx := newFaviconFixture()
210+ fx.repo.byKey["example.com"] = &favicon.Metadata{Key: "example.com", PageURL: "https://example.com", ContentHash: favicon.Hash([]byte("old")), LastCheckedAt: fx.now}
211+ got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true})
212+ if err != nil {
213+ t.Fatal(err)
214+ }
215+ if got.Key != "example.com" || fx.fetcher.calls != 1 || string(got.Bytes) != "png32:fetched" {
216+ t.Fatalf("unexpected resolve after forced refresh: got=%#v calls=%d bytes=%q", got, fx.fetcher.calls, got.Bytes)
217+ }
218+}
219+
220+func TestFaviconResolveBlockingPropagatesFetchErrors(t *testing.T) {
221+ fx := newFaviconFixture()
222+ fx.fetcher.err = errors.New("network unavailable")
223+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true}); !errors.Is(err, fx.fetcher.err) {
224+ t.Fatalf("Resolve err=%v, want %v", err, fx.fetcher.err)
225+ }
226+}
227+
228+func TestFaviconRefreshFromIconURLsUsesPageURLKey(t *testing.T) {
229+ fx := newFaviconFixture()
230+ if err := fx.uc.RefreshFromIconURLs(context.Background(), "https://docs.example.com/page", []string{"https://cdn.example.net/icon.png"}); err != nil {
231+ t.Fatal(err)
232+ }
233+ if _, ok := fx.repo.byKey["docs.example.com"]; !ok {
234+ t.Fatalf("page URL key was not stored; keys=%v", fx.repo.byKey)
235+ }
236+ if _, ok := fx.repo.byKey["cdn.example.net"]; ok {
237+ t.Fatal("icon URL host should not be used as cache key")
238+ }
239+}
240+
241+func TestFaviconObserveConversionErrorDoesNotMutateExistingAssets(t *testing.T) {
242+ fx := newFaviconFixture()
243+ fx.seedOriginal([]byte("old"))
244+ fx.blobs.png["example.com"] = []byte("old-png")
245+ fx.blobs.sized["example.com"] = map[int][]byte{32: []byte("old-32")}
246+ fx.converter.err = errors.New("unsupported image")
247+
248+ 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) {
249+ t.Fatalf("Observe err=%v, want %v", err, fx.converter.err)
250+ }
251+ 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" {
252+ t.Fatalf("existing assets mutated on conversion error")
253+ }
254+ if fx.blobs.writeOriginals != 0 || fx.blobs.removeDerived != 0 || fx.invalidators.calls != 0 {
255+ t.Fatalf("unexpected mutation counters writes=%d remove=%d invalidates=%d", fx.blobs.writeOriginals, fx.blobs.removeDerived, fx.invalidators.calls)
256+ }
257+}
258+
259+func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) {
260+ fx := newFaviconFixture()
261+ fx.seedOriginal([]byte("old"))
262+ fx.blobs.readSizedErr = errors.New("sized store unavailable")
263+ if err := fx.uc.EnsureSized(context.Background(), "example.com", 32); !errors.Is(err, fx.blobs.readSizedErr) {
264+ t.Fatalf("EnsureSized err=%v, want %v", err, fx.blobs.readSizedErr)
265+ }
266+ if fx.converter.calls != 0 {
267+ t.Fatal("converter called after non-miss ReadSizedPNG error")
268+ }
269+}
270+
271+type faviconFixture struct {
272+ repo *fakeFaviconRepo
273+ blobs *fakeBlobStore
274+ converter *fakeConverter
275+ scheduler *fakeScheduler
276+ invalidators *fakeInvalidators
277+ fetcher *fakeFetcher
278+ uc *FaviconUseCase
279+ now time.Time
280+}
281+
282+type faviconTestContextKey string
283+
284+const faviconFixtureBackgroundKey faviconTestContextKey = "bg"
285+
286+func newFaviconFixture() *faviconFixture {
287+ now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
288+ fx := &faviconFixture{
289+ repo: newFakeRepo(),
290+ blobs: newFakeBlobStore(),
291+ converter: &fakeConverter{},
292+ scheduler: &fakeScheduler{seen: map[favicon.Key]bool{}},
293+ invalidators: &fakeInvalidators{},
294+ fetcher: &fakeFetcher{},
295+ now: now,
296+ }
297+ fx.uc = NewFaviconUseCase(FaviconDeps{
298+ Repository: fx.repo,
299+ BlobStore: fx.blobs,
300+ Converter: fx.converter,
301+ Scheduler: fx.scheduler,
302+ Invalidators: fx.invalidators,
303+ Fetcher: fx.fetcher,
304+ Now: func() time.Time { return fx.now },
305+ Background: context.WithValue(context.Background(), faviconFixtureBackgroundKey, true),
306+ })
307+ return fx
308+}
309+func (f *faviconFixture) seed(key string, sized []byte, stale bool) {
310+ checked := f.now
311+ if stale {
312+ checked = f.now.Add(-favicon.DefaultTTL - time.Hour)
313+ }
314+ f.repo.byKey[favicon.Key(key)] = &favicon.Metadata{Key: favicon.Key(key), PageURL: "https://" + key, ContentHash: favicon.Hash([]byte("orig")), LastCheckedAt: checked}
315+ f.blobs.sized[favicon.Key(key)] = map[int][]byte{32: sized}
316+}
317+func (f *faviconFixture) seedOriginal(b []byte) {
318+ const contentType = "image/png"
319+ key := favicon.Key("example.com")
320+ f.repo.byKey[key] = &favicon.Metadata{
321+ Key: key,
322+ PageURL: "https://example.com",
323+ ContentHash: favicon.Hash(b),
324+ ContentType: contentType,
325+ LastCheckedAt: f.now.Add(-time.Hour),
326+ }
327+ f.blobs.original[key] = b
328+ f.blobs.contentTypes[key] = contentType
329+}
330+
331+type fakeFaviconRepo struct {
332+ byKey map[favicon.Key]*favicon.Metadata
333+ err error
334+}
335+
336+func newFakeRepo() *fakeFaviconRepo {
337+ return &fakeFaviconRepo{byKey: map[favicon.Key]*favicon.Metadata{}}
338+}
339+func (r *fakeFaviconRepo) Get(_ context.Context, key favicon.Key) (*favicon.Metadata, error) {
340+ if r.err != nil {
341+ return nil, r.err
342+ }
343+ if m := r.byKey[key]; m != nil {
344+ cp := *m
345+ return &cp, nil
346+ }
347+ return nil, nil
348+}
349+func (r *fakeFaviconRepo) FindFirst(ctx context.Context, keys []favicon.Key) (*favicon.Metadata, error) {
350+ if r.err != nil {
351+ return nil, r.err
352+ }
353+ for _, k := range keys {
354+ if m, _ := r.Get(ctx, k); m != nil {
355+ return m, nil
356+ }
357+ }
358+ return nil, nil
359+}
360+func (r *fakeFaviconRepo) Upsert(_ context.Context, meta favicon.Metadata) error {
361+ if r.err != nil {
362+ return r.err
363+ }
364+ cp := meta
365+ r.byKey[meta.Key] = &cp
366+ return nil
367+}
368+func (r *fakeFaviconRepo) UpdateLastChecked(_ context.Context, key favicon.Key, hash string, checkedAt time.Time) error {
369+ if r.err != nil {
370+ return r.err
371+ }
372+ r.byKey[key].ContentHash = hash
373+ r.byKey[key].LastCheckedAt = checkedAt
374+ return nil
375+}
376+func (r *fakeFaviconRepo) Delete(_ context.Context, key favicon.Key) error {
377+ if r.err != nil {
378+ return r.err
379+ }
380+ delete(r.byKey, key)
381+ return nil
382+}
383+
384+type fakeBlobStore struct {
385+ original, png map[favicon.Key][]byte
386+ sized map[favicon.Key]map[int][]byte
387+ contentTypes map[favicon.Key]string
388+ writeOriginals, removeDerived int
389+ err error
390+ readSizedErr error
391+}
392+
393+func newFakeBlobStore() *fakeBlobStore {
394+ return &fakeBlobStore{original: map[favicon.Key][]byte{}, png: map[favicon.Key][]byte{}, sized: map[favicon.Key]map[int][]byte{}, contentTypes: map[favicon.Key]string{}}
395+}
396+func (b *fakeBlobStore) ReadOriginal(_ context.Context, key favicon.Key) ([]byte, string, error) {
397+ if b.err != nil {
398+ return nil, "", b.err
399+ }
400+ v := b.original[key]
401+ if v == nil {
402+ return nil, "", ErrFaviconMiss
403+ }
404+ return v, b.contentTypes[key], nil
405+}
406+func (b *fakeBlobStore) WriteOriginal(_ context.Context, key favicon.Key, data []byte, ct string) error {
407+ if b.err != nil {
408+ return b.err
409+ }
410+ b.writeOriginals++
411+ b.original[key] = data
412+ b.contentTypes[key] = ct
413+ return nil
414+}
415+func (b *fakeBlobStore) ReadPNG(_ context.Context, key favicon.Key) ([]byte, string, error) {
416+ if b.err != nil {
417+ return nil, "", b.err
418+ }
419+ v := b.png[key]
420+ if v == nil {
421+ return nil, "", ErrFaviconMiss
422+ }
423+ return v, "image/png", nil
424+}
425+func (b *fakeBlobStore) WritePNG(_ context.Context, key favicon.Key, data []byte) error {
426+ if b.err != nil {
427+ return b.err
428+ }
429+ b.png[key] = data
430+ return nil
431+}
432+func (b *fakeBlobStore) ReadSizedPNG(_ context.Context, key favicon.Key, size int) ([]byte, string, error) {
433+ if b.err != nil {
434+ return nil, "", b.err
435+ }
436+ if b.readSizedErr != nil {
437+ return nil, "", b.readSizedErr
438+ }
439+ if b.sized[key] == nil || b.sized[key][size] == nil {
440+ return nil, "", ErrFaviconMiss
441+ }
442+ return b.sized[key][size], "image/png", nil
443+}
444+func (b *fakeBlobStore) WriteSizedPNG(_ context.Context, key favicon.Key, size int, data []byte) error {
445+ if b.err != nil {
446+ return b.err
447+ }
448+ if b.sized[key] == nil {
449+ b.sized[key] = map[int][]byte{}
450+ }
451+ b.sized[key][size] = data
452+ return nil
453+}
454+func (b *fakeBlobStore) RemoveDerived(_ context.Context, key favicon.Key) error {
455+ if b.err != nil {
456+ return b.err
457+ }
458+ b.removeDerived++
459+ delete(b.png, key)
460+ delete(b.sized, key)
461+ return nil
462+}
463+
464+type fakeConverter struct {
465+ calls int
466+ err error
467+}
468+
469+func (c *fakeConverter) Convert(_ context.Context, original []byte, _ string, sizes []int) (*appport.ConvertedFavicon, error) {
470+ c.calls++
471+ if c.err != nil {
472+ return nil, c.err
473+ }
474+ out := &appport.ConvertedFavicon{PNG: append([]byte("png:"), original...), SizedPNG: map[int][]byte{}}
475+ for _, s := range sizes {
476+ out.SizedPNG[s] = append([]byte("png32:"), original...)
477+ }
478+ return out, nil
479+}
480+
481+type fakeScheduler struct {
482+ seen map[favicon.Key]bool
483+ works map[favicon.Key]func(context.Context)
484+ schedules int
485+ usedCallerContext bool
486+}
487+
488+func (s *fakeScheduler) Schedule(ctx context.Context, key favicon.Key, work func(context.Context)) bool {
489+ if ctx.Value(faviconFixtureBackgroundKey) != true {
490+ s.usedCallerContext = true
491+ }
492+ if s.seen[key] {
493+ return false
494+ }
495+ if s.works == nil {
496+ s.works = map[favicon.Key]func(context.Context){}
497+ }
498+ s.seen[key] = true
499+ s.works[key] = work
500+ s.schedules++
501+ return true
502+}
503+
504+func (s *fakeScheduler) run(key favicon.Key) {
505+ if work := s.works[key]; work != nil {
506+ work(context.Background())
507+ }
508+}
509+
510+type fakeInvalidators struct{ calls int }
511+
512+func (i *fakeInvalidators) InvalidateAll(_ context.Context, _ favicon.Key) error {
513+ i.calls++
514+ return nil
515+}
516+
517+type fakeFetcher struct {
518+ calls int
519+ err error
520+}
521+
522+func (f *fakeFetcher) Fetch(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
523+ f.calls++
524+ if f.err != nil {
525+ return nil, f.err
526+ }
527+ iconURL := req.IconURL
528+ if iconURL == "" {
529+ iconURL = "https://example.com/favicon.ico"
530+ }
531+ return &appport.FaviconFetchedIcon{PageURL: req.PageURL, IconURL: iconURL, Bytes: []byte("fetched"), Source: favicon.SourceDuckDuckGo, ContentType: "image/png"}, nil
532+}
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 +25 -0
@@ -0,0 +1,25 @@
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+ SourceImported Source = "imported"
13+ SourceManual Source = "manual"
14+)
15+
16+type Metadata struct {
17+ Key Key
18+ SourceURL string
19+ PageURL string
20+ Source Source
21+ ContentHash string
22+ ContentType string
23+ UpdatedAt time.Time
24+ LastCheckedAt time.Time
25+}
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 +145 -0
@@ -0,0 +1,145 @@
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+ trimmed := strings.TrimSpace(rawIconURL)
91+ if trimmed == "" {
92+ return "", false
93+ }
94+ icon, err := url.Parse(trimmed)
95+ if err != nil {
96+ return "", false
97+ }
98+ if icon.IsAbs() {
99+ if !isHTTPScheme(icon.Scheme) || icon.Host == "" {
100+ return "", false
101+ }
102+ return icon.String(), true
103+ }
104+ base, err := url.Parse(pageURL)
105+ if err != nil || !isHTTPScheme(base.Scheme) || base.Host == "" {
106+ return "", false
107+ }
108+ resolved := base.ResolveReference(icon)
109+ if !isHTTPScheme(resolved.Scheme) || resolved.Host == "" {
110+ return "", false
111+ }
112+ return resolved.String(), true
113+}
114+
115+func originFaviconURL(pageURL string) (string, bool) {
116+ u, err := url.Parse(pageURL)
117+ if err != nil || !isHTTPScheme(u.Scheme) || u.Host == "" {
118+ return "", false
119+ }
120+ u.Path = "/favicon.ico"
121+ u.RawPath = ""
122+ u.RawQuery = ""
123+ u.Fragment = ""
124+ return u.String(), true
125+}
126+
127+func isHTTPScheme(scheme string) bool {
128+ return scheme == "http" || scheme == "https"
129+}
130+
131+func dedupeStrings(values []string) []string {
132+ seen := make(map[string]struct{}, len(values))
133+ out := make([]string, 0, len(values))
134+ for _, value := range values {
135+ if value == "" {
136+ continue
137+ }
138+ if _, ok := seen[value]; ok {
139+ continue
140+ }
141+ seen[value] = struct{}{}
142+ out = append(out, value)
143+ }
144+ return out
145+}
A internal/infrastructure/cef/favicon_discovery_test.go +108 -0
@@ -0,0 +1,108 @@
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 TestFaviconDiscoveryIgnoresWhitespaceOnlyHrefs(t *testing.T) {
49+ got := DiscoverFaviconCandidates("https://example.com/path", `<link rel="icon" href=" ">`)
50+ require.Equal(t, []string{"https://example.com/favicon.ico"}, got)
51+}
52+
53+func TestCEFOnFaviconUrlchangeForwardsOrderedCandidates(t *testing.T) {
54+ prevDecode := decodeCEFStringList
55+ decodeCEFStringList = func(list purecef.StringList) []string {
56+ require.Equal(t, purecef.StringList(42), list)
57+ return []string{"/favicon.png", "https://cdn.example.com/icon.ico", "javascript:alert(1)"}
58+ }
59+ defer func() { decodeCEFStringList = prevDecode }()
60+
61+ var page string
62+ var icons []string
63+ wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{
64+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
65+ }}
66+
67+ (&handlerSet{wv: wv}).OnFaviconUrlchange(nil, purecef.StringList(42))
68+
69+ require.Equal(t, "https://example.com/docs/page", page)
70+ require.Equal(t, []string{"https://example.com/favicon.png", "https://cdn.example.com/icon.ico"}, icons)
71+}
72+
73+func TestCEFLoadingFinishedDiscoversMetadataBeforeFallback(t *testing.T) {
74+ prevNewStringVisitor := cefNewStringVisitor
75+ cefNewStringVisitor = func(visitor purecef.StringVisitor) purecef.StringVisitor { return visitor }
76+ defer func() { cefNewStringVisitor = prevNewStringVisitor }()
77+
78+ browser := cefmocks.NewMockBrowser(t)
79+ frame := cefmocks.NewMockFrame(t)
80+ browser.EXPECT().GetMainFrame().Return(frame)
81+ frame.EXPECT().GetSource(mock.Anything).Run(func(visitor purecef.StringVisitor) {
82+ visitor.Visit(`<html><head><link rel="icon" href="/meta.png"></head></html>`)
83+ }).Return()
84+
85+ var page string
86+ var icons []string
87+ wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{
88+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
89+ }}
90+
91+ (&handlerSet{wv: wv}).OnLoadingStateChange(browser, 0, 1, 0)
92+
93+ require.Equal(t, "https://example.com/docs/page", page)
94+ require.Equal(t, []string{"https://example.com/meta.png", "https://example.com/favicon.ico"}, icons)
95+}
96+
97+func TestCEFLoadingFinishedForwardsFallbackCandidate(t *testing.T) {
98+ var page string
99+ var icons []string
100+ wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{
101+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
102+ }}
103+
104+ (&handlerSet{wv: wv}).OnLoadingStateChange(nil, 0, 1, 0)
105+
106+ require.Equal(t, "https://example.com/docs/page", page)
107+ require.Equal(t, []string{"https://example.com/favicon.ico"}, icons)
108+}
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 +41 -114
@@ -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,29 @@ 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 {
840+ reqCtx, cancel := context.WithCancel(ctx) //nolint:gosec // canceled by load completion or CEF Cancel callback
837841 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"},
842+ ctx: reqCtx,
843+ cancel: cancel,
844+ resolver: resolver,
845+ domain: domain,
846+ size: size,
847+ done: make(chan struct{}),
848+ headers: map[string]string{"Cache-Control": "no-store"},
843849 })
844850 }
845851
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-
919852 type faviconResourceHandler struct {
920- service port.FaviconService
853+ ctx context.Context
854+ cancel context.CancelFunc
855+ cancelOnce sync.Once
856+ resolver port.FaviconSystemviewResolver
921857 domain string
922858 size int
923859 headers map[string]string
@@ -931,38 +867,23 @@ type faviconResourceHandler struct {
931867
932868 func (rh *faviconResourceHandler) load() {
933869 defer close(rh.done)
870+ defer rh.cancelRequest()
934871 rh.statusCode = http.StatusNotFound
935872 rh.contentType = "application/json"
936873 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) {
874+ if rh.resolver == nil {
948875 return
949876 }
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 {
877+ resolved, err := rh.resolver.ResolveSystemviewIcon(rh.ctx, rh.domain, rh.size)
878+ if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
956879 return
957880 }
958881 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- })
882+ rh.contentType = resolved.ContentType
883+ if rh.contentType == "" {
884+ rh.contentType = "image/png"
885+ }
886+ rh.data = resolved.Bytes
966887 }
967888
968889 func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {
@@ -1038,7 +959,13 @@ func (rh *faviconResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int
1038959 return 0
1039960 }
1040961
1041-func (rh *faviconResourceHandler) Cancel() {}
962+func (rh *faviconResourceHandler) Cancel() { rh.cancelRequest() }
963+
964+func (rh *faviconResourceHandler) cancelRequest() {
965+ if rh.cancel != nil {
966+ rh.cancelOnce.Do(rh.cancel)
967+ }
968+}
1042969
1043970 // Open handles the request immediately (synchronous).
1044971 func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ 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 +114 -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,79 @@ 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+ continue
392+ }
393+ last := len(wv.faviconSourceVisitors) - 1
394+ copy(wv.faviconSourceVisitors[i:], wv.faviconSourceVisitors[i+1:])
395+ wv.faviconSourceVisitors[last] = faviconSourceVisitorRetention{}
396+ wv.faviconSourceVisitors = wv.faviconSourceVisitors[:last]
397+ return
398+ }
399+ }
400+}
401+
402+func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pageURL string, cb *port.WebViewCallbacks) {
403+ if h == nil || h.wv == nil || cb == nil || cb.OnFaviconURLChanged == nil {
404+ return
405+ }
406+ fallback := fallbackFaviconCandidates(pageURL)
407+ emit := func(candidates []string) {
408+ if len(candidates) == 0 {
409+ return
410+ }
411+ h.wv.runOnGTK(func() {
412+ cb.OnFaviconURLChanged(pageURL, candidates)
413+ })
414+ }
415+ if browser == nil {
416+ emit(fallback)
417+ return
418+ }
419+ frame := browser.GetMainFrame()
420+ if frame == nil {
421+ emit(fallback)
422+ return
423+ }
424+ var releaseOnce sync.Once
425+ var release func()
426+ visitor := cefNewStringVisitor(faviconSourceVisitor{visit: func(source string) {
427+ releaseOnce.Do(func() {
428+ if release != nil {
429+ release()
430+ }
431+ })
432+ candidates := DiscoverFaviconCandidates(pageURL, source)
433+ if len(candidates) == 0 {
434+ candidates = fallback
435+ }
436+ emit(candidates)
437+ }})
438+ release = h.wv.retainFaviconSourceVisitor(visitor)
439+ time.AfterFunc(5*time.Second, func() {
440+ releaseOnce.Do(func() {
441+ if release != nil {
442+ release()
443+ }
444+ })
445+ })
446+ frame.GetSource(visitor)
447+}
448+
337449 // OnLoadStart fires LoadCommitted for main frame navigations.
338450 // In CEF this callback runs after navigation commit, so it is the closest
339451 // 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 +18 -70
@@ -15,24 +15,24 @@ 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(),
31- }
28+ return &Service{cache: NewCache(cacheDir)}
29+}
30+
31+func (s *Service) Cache() *Cache {
32+ return s.cache
3233 }
3334
34-// Get returns favicon bytes for a domain.
35-// Checks memory cache, then disk cache, then fetches from external API.
35+// Get returns favicon bytes for a domain from the legacy cache only.
3636 func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
3737 if domain == "" {
3838 return nil, nil
@@ -46,7 +46,6 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
4646 return assets.LogoSVG, nil
4747 }
4848
49- // Check cache first (memory + disk)
5049 if data, ok := s.cache.Get(ctx, domain); ok {
5150 log.Debug().
5251 Str("domain", domain).
@@ -54,24 +53,8 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
5453 Msg("favicon: Service.Get cache hit")
5554 return data, nil
5655 }
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
56+ log.Debug().Str("domain", domain).Msg("favicon: Service.Get cache miss")
57+ return nil, nil
7558 }
7659
7760 // IsInternalURL checks if a URL uses the internal dumb:// scheme.
@@ -127,35 +110,10 @@ func (s *Service) Close() {
127110 s.cache.Close()
128111 }
129112
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")
113+// EnsureDiskCache is a legacy no-op. Disk population is handled by explicit
114+// texture/blob writes after EnsureCacheDir; remote refresh policy belongs to the application usecase.
115+func (*Service) EnsureDiskCache(ctx context.Context, domain string) {
116+ logging.FromContext(ctx).Debug().Str("domain", domain).Msg("favicon: legacy EnsureDiskCache skipped")
159117 }
160118
161119 // DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
@@ -168,8 +126,8 @@ func (s *Service) HasPNGSizedOnDisk(domain string, size int) bool {
168126 return s.cache.HasPNGSizedOnDisk(domain, size)
169127 }
170128
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.
129+// EnsureSizedPNG creates or overwrites a resized PNG from the original PNG.
130+// This is a legacy adapter method; new code should use the favicon usecase.
173131 func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) error {
174132 if domain == "" {
175133 return nil
@@ -180,16 +138,6 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e
180138 Int("size", size).
181139 Msg("favicon: EnsureSizedPNG begin")
182140
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
193141 srcPath := s.DiskPathPNG(domain)
194142 if srcPath == "" || !s.HasPNGOnDisk(domain) {
195143 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 +18 -17
@@ -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"
@@ -73,9 +71,10 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
7371
7472 // DumbSchemeHandler handles dumb:// URI scheme requests.
7573 type DumbSchemeHandler struct {
74+ ctx context.Context
7675 handlers map[string]PageHandler
7776 assets embed.FS
78- faviconService port.FaviconService
77+ faviconResolver port.FaviconSystemviewResolver
7978 assetDir string // default subdirectory within embed.FS (e.g., "systemviews")
8079 logger zerolog.Logger
8180 mu sync.RWMutex
@@ -88,6 +87,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
8887 log := logging.FromContext(ctx)
8988
9089 h := &DumbSchemeHandler{
90+ ctx: ctx,
9191 handlers: make(map[string]PageHandler),
9292 assetDir: systemviewsAssetDir,
9393 logger: log.With().Str("component", "scheme-handler").Logger(),
@@ -116,10 +116,10 @@ func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload fun
116116 h.logger.Debug().Msg("config payload builders configured")
117117 }
118118
119-func (h *DumbSchemeHandler) SetFaviconService(service port.FaviconService) {
119+func (h *DumbSchemeHandler) SetFaviconResolver(resolver port.FaviconSystemviewResolver) {
120120 h.mu.Lock()
121121 defer h.mu.Unlock()
122- h.faviconService = service
122+ h.faviconResolver = resolver
123123 }
124124
125125 // registerDefaults sets up default page handlers.
@@ -210,9 +210,9 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
210210 return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
211211 }
212212 h.mu.RLock()
213- service := h.faviconService
213+ resolver := h.faviconResolver
214214 h.mu.RUnlock()
215- if service == nil {
215+ if resolver == nil {
216216 return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable")
217217 }
218218 if req == nil {
@@ -222,7 +222,7 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
222222 if err != nil {
223223 return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL")
224224 }
225- domain := domainurl.CanonicalDomain(parsed.Query().Get("domain"))
225+ domain := strings.TrimSpace(parsed.Query().Get("domain"))
226226 if domain == "" {
227227 return privateJSONErrorResponse(http.StatusBadRequest, "missing domain")
228228 }
@@ -235,20 +235,21 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
235235 size = parsedSize
236236 }
237237
238- if !service.HasPNGSizedOnDisk(domain, size) {
239- return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
238+ resolveCtx := h.ctx
239+ if resolveCtx == nil {
240+ resolveCtx = context.Background()
240241 }
241- diskPath := service.DiskPathPNGSized(domain, size)
242- if diskPath == "" {
242+ resolved, err := resolver.ResolveSystemviewIcon(resolveCtx, domain, size)
243+ if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
243244 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
244245 }
245- data, err := os.ReadFile(diskPath)
246- if err != nil || len(data) == 0 {
247- return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
246+ contentType := resolved.ContentType
247+ if contentType == "" {
248+ contentType = "image/png"
248249 }
249250 return &SchemeResponse{
250- Data: data,
251- ContentType: "image/png",
251+ Data: resolved.Bytes,
252+ ContentType: contentType,
252253 StatusCode: http.StatusOK,
253254 Headers: map[string]string{"Cache-Control": "no-store"},
254255 SuppressDefaultHeaders: true,
M internal/ui/adapter/favicon.go +200 -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,8 +20,10 @@ 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
26+ aliasesByKey map[favicon.Key]map[string]struct{}
2427 mu sync.RWMutex
2528 warnMu sync.Mutex
2629 warnCounts map[string]int
@@ -57,11 +60,22 @@ type FaviconAdapterConfig struct {
5760 // NewFaviconAdapter creates a new FaviconAdapter.
5861 // The faviconDB can be nil if a favicon database is not available.
5962 func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {
63+ return NewFaviconAdapterWithResolver(service, nil, faviconDB, cfg)
64+}
65+
66+func NewFaviconAdapterWithResolver(
67+ service port.FaviconService,
68+ resolver port.FaviconResolver,
69+ faviconDB port.FaviconDatabase,
70+ cfg FaviconAdapterConfig,
71+) *FaviconAdapter {
6072 disabled := os.Getenv(disableFaviconsEnv) == "1"
6173 return &FaviconAdapter{
6274 service: service,
75+ resolver: resolver,
6376 faviconDB: faviconDB,
6477 textureCache: make(map[string]*gdk.Texture),
78+ aliasesByKey: make(map[favicon.Key]map[string]struct{}),
6579 warnCounts: make(map[string]int),
6680 isInternalURL: cfg.IsInternalURL,
6781 internalDomain: cfg.InternalDomain,
@@ -132,26 +146,34 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
132146 return
133147 }
134148
149+ // Check application resolver before falling back to the legacy service.
150+ if texture := a.resolveTexture(ctx, pageURL, true); texture != nil {
151+ callback(texture)
152+ return
153+ }
154+
135155 // 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
156+ if a.service != nil {
157+ if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
158+ log.Debug().
159+ Str("domain", domain).
160+ Int("bytes", len(data)).
161+ Msg("favicon: service cache hit")
162+ texture := a.textureFromBytesOnGTK(ctx, data)
163+ if texture != nil {
164+ a.setTexture(domain, texture)
165+ // Ensure sized PNG exists for CLI tools (async, idempotent)
166+ go a.ensureSizedPNG(ctx, domain)
167+ callback(texture)
168+ return
169+ }
148170 }
149171 }
150172
151173 // Try engine FaviconDatabase if available
152174 if a.faviconDB != nil {
153175 a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {
154- a.handleEngineFaviconDBTexture(ctx, domain, callback, tex)
176+ a.handleEngineFaviconDBTexture(ctx, pageURL, domain, callback, tex)
155177 })
156178 return
157179 }
@@ -162,6 +184,9 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
162184 }
163185
164186 func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string, callback func(*gdk.Texture), texture *gdk.Texture) {
187+ if callback == nil {
188+ return
189+ }
165190 log := logging.FromContext(ctx)
166191 log.Debug().
167192 Str("domain", domain).
@@ -178,7 +203,49 @@ func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string,
178203 glib.IdleAdd(&cb, 0)
179204 }
180205
206+func (a *FaviconAdapter) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string, callback func(*gdk.Texture)) {
207+ domain := domainurl.ExtractDomain(pageURL)
208+ if a.resolver == nil {
209+ a.invokeCallbackOnGTK(ctx, domain, callback, nil)
210+ return
211+ }
212+ go func() {
213+ if err := a.resolver.RefreshFromIconURLs(ctx, pageURL, iconURLs); err != nil {
214+ logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: RefreshFromIconURLs failed")
215+ }
216+ if callback == nil {
217+ return
218+ }
219+ a.GetOrFetch(ctx, pageURL, func(texture *gdk.Texture) {
220+ a.invokeCallbackOnGTK(ctx, domain, callback, texture)
221+ })
222+ }()
223+}
224+
225+func (a *FaviconAdapter) resolveTexture(ctx context.Context, pageURL string, scheduleRefresh bool) *gdk.Texture {
226+ if a.resolver == nil {
227+ return nil
228+ }
229+ options := port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI}
230+ if scheduleRefresh {
231+ options.ScheduleBackgroundRefresh = true
232+ }
233+ resolved, err := a.resolver.Resolve(ctx, pageURL, 0, options)
234+ if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
235+ return nil
236+ }
237+ texture := a.textureFromBytesOnGTK(ctx, resolved.Bytes)
238+ if texture != nil {
239+ a.setResolvedTexture(pageURL, resolved.Key, texture)
240+ }
241+ return texture
242+}
243+
181244 func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) {
245+ if a.service == nil {
246+ a.invokeCallbackOnGTK(ctx, domain, callback, nil)
247+ return
248+ }
182249 go func() {
183250 log := logging.FromContext(ctx)
184251 log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin")
@@ -209,7 +276,12 @@ func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, cal
209276 }()
210277 }
211278
212-func (a *FaviconAdapter) handleEngineFaviconDBTexture(ctx context.Context, domain string, callback func(*gdk.Texture), tex port.Texture) {
279+func (a *FaviconAdapter) handleEngineFaviconDBTexture(
280+ ctx context.Context,
281+ pageURL, domain string,
282+ callback func(*gdk.Texture),
283+ tex port.Texture,
284+) {
213285 log := logging.FromContext(ctx)
214286 log.Debug().
215287 Str("domain", domain).
@@ -228,7 +300,7 @@ func (a *FaviconAdapter) handleEngineFaviconDBTexture(ctx context.Context, domai
228300 }
229301 a.setTexture(domain, gdkTex)
230302 a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex)
231- a.saveFaviconToDisk(ctx, domain, gdkTex)
303+ a.storeTextureObservation(ctx, pageURL, gdkTex)
232304 }
233305
234306 // StoreFromWebKit stores a favicon texture received from WebKit signals.
@@ -252,7 +324,12 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te
252324 // Store in texture cache
253325 a.setTexture(domain, texture)
254326
255- // Export as PNG and ensure disk cache
327+ if a.resolver != nil {
328+ a.storeTextureObservation(ctx, pageURL, texture)
329+ return
330+ }
331+
332+ // Legacy fallback for callers not yet wired to the application resolver.
256333 a.saveFaviconToDisk(ctx, domain, texture)
257334 }
258335
@@ -269,14 +346,55 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
269346 currentDomain := domainurl.ExtractDomain(currentURL)
270347 if originDomain != "" && originDomain != currentDomain {
271348 a.setTexture(originDomain, texture)
272- a.saveFaviconToDisk(ctx, originDomain, texture)
349+ if a.resolver != nil {
350+ a.storeTextureObservation(ctx, originURL, texture)
351+ } else {
352+ a.saveFaviconToDisk(ctx, originDomain, texture)
353+ }
273354 }
274355 }
275356 }
276357
358+func (a *FaviconAdapter) storeTextureObservation(ctx context.Context, pageURL string, texture *gdk.Texture) {
359+ if a.resolver == nil || texture == nil || pageURL == "" {
360+ return
361+ }
362+ cb := glib.SourceFunc(func(_ uintptr) bool {
363+ tmp, err := os.CreateTemp("", "dumber-favicon-*.png")
364+ if err != nil {
365+ logging.FromContext(ctx).Debug().Err(err).Msg("favicon: failed to create temp PNG for observation")
366+ return false
367+ }
368+ path := tmp.Name()
369+ _ = tmp.Close()
370+ defer func() { _ = os.Remove(path) }()
371+ if ok := texture.SaveToPng(path); !ok {
372+ logging.FromContext(ctx).Debug().Str("page_url", pageURL).Msg("favicon: failed to export texture for observation")
373+ return false
374+ }
375+ data, err := os.ReadFile(path)
376+ if err != nil || len(data) == 0 {
377+ logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: failed to read exported observation")
378+ return false
379+ }
380+ go func() {
381+ // Engine-provided textures do not expose the original icon URL, so pageURL
382+ // is passed as the observation iconURL fallback for SourceEngine records.
383+ if _, err := a.resolver.Observe(ctx, pageURL, pageURL, data, favicon.SourceEngine, "image/png"); err != nil {
384+ logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: failed to observe engine texture")
385+ }
386+ }()
387+ return false
388+ })
389+ glib.IdleAdd(&cb, 0)
390+}
391+
277392 // saveFaviconToDisk exports a favicon texture to PNG and ensures disk cache is populated.
278393 // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.
279394 func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {
395+ if a.service == nil {
396+ return
397+ }
280398 log := logging.FromContext(ctx)
281399 cacheDirWarnKey := "cache-dir"
282400 savePNGWarnKey := "save-png:" + domain
@@ -359,16 +477,23 @@ func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *
359477 return texture
360478 }
361479
480+ // Check application resolver before falling back to the legacy service.
481+ if texture := a.resolveTexture(ctx, pageURL, false); texture != nil {
482+ return texture
483+ }
484+
362485 // 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
486+ if a.service != nil {
487+ if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
488+ log.Debug().
489+ Str("domain", domain).
490+ Int("bytes", len(data)).
491+ Msg("favicon: PreloadFromCache service hit")
492+ texture := a.textureFromBytesOnGTK(ctx, data)
493+ if texture != nil {
494+ a.setTexture(domain, texture)
495+ return texture
496+ }
372497 }
373498 }
374499
@@ -384,13 +509,29 @@ func (a *FaviconAdapter) Service() port.FaviconService {
384509
385510 // Close shuts down the adapter and underlying service.
386511 func (a *FaviconAdapter) Close() {
387- a.service.Close()
512+ if a.service != nil {
513+ a.service.Close()
514+ }
515+}
516+
517+// Invalidate clears one resolved-key texture cache entry and all domain aliases
518+// explicitly bound to that canonical favicon key.
519+func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {
520+ a.mu.Lock()
521+ delete(a.textureCache, string(key))
522+ for alias := range a.aliasesByKey[key] {
523+ delete(a.textureCache, alias)
524+ }
525+ delete(a.aliasesByKey, key)
526+ a.mu.Unlock()
527+ return nil
388528 }
389529
390530 // Clear removes all entries from the texture cache.
391531 func (a *FaviconAdapter) Clear() {
392532 a.mu.Lock()
393533 a.textureCache = make(map[string]*gdk.Texture)
534+ a.aliasesByKey = make(map[favicon.Key]map[string]struct{})
394535 a.mu.Unlock()
395536 }
396537
@@ -402,6 +543,37 @@ func (a *FaviconAdapter) Size() int {
402543 return size
403544 }
404545
546+// setResolvedTexture stores resolver results under both the resolved favicon key
547+// and the current page domain. Aliases are indexed by canonical key so
548+// invalidation does not depend on pointer equality with the latest texture.
549+func (a *FaviconAdapter) setResolvedTexture(pageURL string, key favicon.Key, texture *gdk.Texture) {
550+ if key == "" || texture == nil {
551+ return
552+ }
553+ domain := domainurl.ExtractDomain(pageURL)
554+ a.mu.Lock()
555+ defer a.mu.Unlock()
556+ a.textureCache[string(key)] = texture
557+ if domain == "" {
558+ return
559+ }
560+ a.removeAliasLocked(domain)
561+ a.textureCache[domain] = texture
562+ if a.aliasesByKey[key] == nil {
563+ a.aliasesByKey[key] = make(map[string]struct{})
564+ }
565+ a.aliasesByKey[key][domain] = struct{}{}
566+}
567+
568+func (a *FaviconAdapter) removeAliasLocked(alias string) {
569+ for key, aliases := range a.aliasesByKey {
570+ delete(aliases, alias)
571+ if len(aliases) == 0 {
572+ delete(a.aliasesByKey, key)
573+ }
574+ }
575+}
576+
405577 // setTexture stores a texture in the cache.
406578 func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
407579 if domain == "" || texture == nil {
@@ -415,7 +587,7 @@ func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
415587 // ensureSizedPNG ensures the sized PNG exists for CLI tools.
416588 // This is idempotent - it only creates the file if it doesn't exist.
417589 func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
418- if domain == "" {
590+ if domain == "" || a.service == nil {
419591 return
420592 }
421593
M internal/ui/adapter/favicon_test.go +32 -0
@@ -1,12 +1,44 @@
11 package adapter
22
33 import (
4+ "context"
45 "sync/atomic"
56 "testing"
67
8+ "github.com/bnema/dumber/internal/domain/favicon"
9+ "github.com/bnema/puregotk/v4/gdk"
710 "github.com/rs/zerolog"
811 )
912
13+func TestFaviconAdapterInvalidateRemovesResolvedTextureAliases(t *testing.T) {
14+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
15+ texture := &gdk.Texture{}
16+ adapter.setResolvedTexture("https://docs.example.com/page", favicon.Key("example.com"), texture)
17+
18+ if adapter.GetTexture("example.com") == nil || adapter.GetTexture("docs.example.com") == nil {
19+ t.Fatal("expected both resolved key and domain alias to be cached")
20+ }
21+ if err := adapter.Invalidate(context.Background(), "example.com"); err != nil {
22+ t.Fatal(err)
23+ }
24+ if adapter.GetTexture("example.com") != nil || adapter.GetTexture("docs.example.com") != nil {
25+ t.Fatal("expected invalidation to remove resolved key and alias")
26+ }
27+}
28+
29+func TestFaviconAdapterInvalidateRemovesOlderAliasesAfterKeyRebind(t *testing.T) {
30+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
31+ adapter.setResolvedTexture("https://docs.example.com/page", favicon.Key("example.com"), &gdk.Texture{})
32+ adapter.setResolvedTexture("https://blog.example.com/page", favicon.Key("example.com"), &gdk.Texture{})
33+
34+ if err := adapter.Invalidate(context.Background(), "example.com"); err != nil {
35+ t.Fatal(err)
36+ }
37+ if adapter.GetTexture("example.com") != nil || adapter.GetTexture("docs.example.com") != nil || adapter.GetTexture("blog.example.com") != nil {
38+ t.Fatal("expected invalidation to remove canonical key and all aliases")
39+ }
40+}
41+
1042 func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {
1143 adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
1244
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