detached 8a0ce09

feat(favicon): add cache refresh resolution pipeline

commit
8a0ce09ffd1c41b35c6fd1dd7fb78231039e0233
author
brice · · 2026-06-07 18:17 UTC
parent
5a2fbe6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
51 files · +3665 -362
M cmd/dumber/main.go +55 -23
@@ -26,7 +26,7 @@ import (
26 "github.com/bnema/dumber/internal/infrastructure/deps"26 "github.com/bnema/dumber/internal/infrastructure/deps"
27 "github.com/bnema/dumber/internal/infrastructure/desktop"27 "github.com/bnema/dumber/internal/infrastructure/desktop"
28 renderenv "github.com/bnema/dumber/internal/infrastructure/env"28 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"
30 "github.com/bnema/dumber/internal/infrastructure/filesystem"30 "github.com/bnema/dumber/internal/infrastructure/filesystem"
31 "github.com/bnema/dumber/internal/infrastructure/idle"31 "github.com/bnema/dumber/internal/infrastructure/idle"
32 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"32 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
@@ -688,6 +688,7 @@ type repositories struct {
688 permission port.PermissionRepository688 permission port.PermissionRepository
689 session repository.SessionRepository689 session repository.SessionRepository
690 sessionState repository.SessionStateRepository690 sessionState repository.SessionStateRepository
691+ faviconRepo port.FaviconRepository
691 }692 }
692 693
693 func createRepositories(db *sql.DB) *repositories {694 func createRepositories(db *sql.DB) *repositories {
@@ -700,6 +701,7 @@ func createRepositories(db *sql.DB) *repositories {
700 permission: sqlite.NewPermissionRepository(db),701 permission: sqlite.NewPermissionRepository(db),
701 session: sqlite.NewSessionRepository(db),702 session: sqlite.NewSessionRepository(db),
702 sessionState: sqlite.NewSessionStateRepository(db),703 sessionState: sqlite.NewSessionStateRepository(db),
704+ faviconRepo: sqlite.NewFaviconRepository(db),
703 }705 }
704 }706 }
705 707
@@ -713,6 +715,7 @@ func createLazyRepositories(provider port.DatabaseProvider) *repositories {
713 permission: sqlite.NewLazyPermissionRepository(provider),715 permission: sqlite.NewLazyPermissionRepository(provider),
714 session: sqlite.NewLazySessionRepository(provider),716 session: sqlite.NewLazySessionRepository(provider),
715 sessionState: sqlite.NewLazySessionStateRepository(provider),717 sessionState: sqlite.NewLazySessionStateRepository(provider),
718+ faviconRepo: sqlite.NewLazyFaviconRepository(provider),
716 }719 }
717 }720 }
718 721
@@ -731,7 +734,8 @@ type useCases struct {
731 checkUpdate *usecase.CheckUpdateUseCase734 checkUpdate *usecase.CheckUpdateUseCase
732 applyUpdate *usecase.ApplyUpdateUseCase735 applyUpdate *usecase.ApplyUpdateUseCase
733 clipboard port.Clipboard736 clipboard port.Clipboard
734- favicon *favicon.Service737+ favicon *infrafavicon.Service
738+ faviconUC *usecase.FaviconUseCase
735 }739 }
736 740
737 func createUseCases(repos *repositories, cfg *config.Config) *useCases {741 func createUseCases(repos *repositories, cfg *config.Config) *useCases {
@@ -744,6 +748,17 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
744 748
745 clipboardAdapter := clipboard.New()749 clipboardAdapter := clipboard.New()
746 faviconCacheDir, _ := config.GetFaviconCacheDir()750 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+ }
747 xdgDirs, _ := config.GetXDGDirs()762 xdgDirs, _ := config.GetXDGDirs()
748 stateDir, _ := config.GetStateDir()763 stateDir, _ := config.GetStateDir()
749 defaultZoom := cfg.DefaultWebpageZoom764 defaultZoom := cfg.DefaultWebpageZoom
@@ -776,7 +791,8 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
776 checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),791 checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),
777 applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),792 applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome),
778 clipboard: clipboardAdapter,793 clipboard: clipboardAdapter,
779- favicon: favicon.NewService(faviconCacheDir),794+ favicon: faviconService,
795+ faviconUC: faviconUC,
780 }796 }
781 }797 }
782 798
@@ -799,6 +815,20 @@ func isCEFSubprocess(args []string) bool {
799 !strings.HasPrefix(args[2], "-")815 !strings.HasPrefix(args[2], "-")
800 }816 }
801 817
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+
802 func buildUIDependencies(832 func buildUIDependencies(
803 ctx context.Context,833 ctx context.Context,
804 cfg *config.Config,834 cfg *config.Config,
@@ -839,27 +869,29 @@ func buildUIDependencies(
839 runtimeProfile.Mode == runtimeprofile.ModeDev,869 runtimeProfile.Mode == runtimeprofile.ModeDev,
840 bootstrap.ResolveXDGRuntimeDir(runtimeProfile),870 bootstrap.ResolveXDGRuntimeDir(runtimeProfile),
841 ),871 ),
842- Engine: engine,872+ Engine: engine,
843- FilterManager: filterManager,873+ FilterManager: filterManager,
844- HistoryRepo: repos.history,874+ HistoryRepo: repos.history,
845- FavoriteRepo: repos.favorite,875+ FavoriteRepo: repos.favorite,
846- ZoomRepo: repos.zoom,876+ ZoomRepo: repos.zoom,
847- PermissionRepo: repos.permission,877+ PermissionRepo: repos.permission,
848- TabsUC: uc.tabs,878+ TabsUC: uc.tabs,
849- PanesUC: uc.panes,879+ PanesUC: uc.panes,
850- HistoryUC: uc.history,880+ HistoryUC: uc.history,
851- FavoritesUC: uc.favorites,881+ FavoritesUC: uc.favorites,
852- ZoomUC: uc.zoom,882+ ZoomUC: uc.zoom,
853- PermissionUC: uc.permission,883+ PermissionUC: uc.permission,
854- NavigateUC: uc.navigate,884+ NavigateUC: uc.navigate,
855- CopyURLUC: uc.copyURL,885+ CopyURLUC: uc.copyURL,
856- Clipboard: uc.clipboard,886+ Clipboard: uc.clipboard,
857- FaviconService: uc.favicon,887+ FaviconService: legacyFaviconService(uc),
888+ FaviconResolver: faviconResolver(uc),
889+ SystemviewFaviconResolver: faviconResolver(uc),
858 FaviconAdapterConfig: adapter.FaviconAdapterConfig{890 FaviconAdapterConfig: adapter.FaviconAdapterConfig{
859- IsInternalURL: favicon.IsInternalURL,891+ IsInternalURL: infrafavicon.IsInternalURL,
860- InternalDomain: favicon.InternalDomain,892+ InternalDomain: infrafavicon.InternalDomain,
861- NormalizedIconSize: favicon.NormalizedIconSize,893+ NormalizedIconSize: infrafavicon.NormalizedIconSize,
862- GetLogoBytes: favicon.GetLogoBytes,894+ GetLogoBytes: infrafavicon.GetLogoBytes,
863 },895 },
864 IdleInhibitor: idleInhibitor,896 IdleInhibitor: idleInhibitor,
865 SessionRepo: repos.session,897 SessionRepo: repos.session,
M cmd/dumber/main_test.go +15 -0
@@ -73,6 +73,21 @@ func TestLaunchModeFromArgs_OmniboxFlagFallsBackToCLI(t *testing.T) {
73 }73 }
74 }74 }
75 75
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+
76 func TestIsCEFSubprocess(t *testing.T) {91 func TestIsCEFSubprocess(t *testing.T) {
77 tests := []struct {92 tests := []struct {
78 name string93 name string
M go.mod +1 -1
@@ -23,6 +23,7 @@ require (
23 github.com/spf13/viper v1.21.023 github.com/spf13/viper v1.21.0
24 github.com/stretchr/testify v1.11.124 github.com/stretchr/testify v1.11.1
25 golang.org/x/image v0.41.025 golang.org/x/image v0.41.0
26+ golang.org/x/net v0.55.0
26 golang.org/x/sys v0.45.027 golang.org/x/sys v0.45.0
27 gopkg.in/yaml.v3 v3.0.128 gopkg.in/yaml.v3 v3.0.1
28 )29 )
@@ -74,7 +75,6 @@ require (
74 go.yaml.in/yaml/v3 v3.0.4 // indirect75 go.yaml.in/yaml/v3 v3.0.4 // indirect
75 golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect76 golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
76 golang.org/x/mod v0.36.0 // indirect77 golang.org/x/mod v0.36.0 // indirect
77- golang.org/x/net v0.54.0 // indirect
78 golang.org/x/sync v0.20.0 // indirect78 golang.org/x/sync v0.20.0 // indirect
79 golang.org/x/text v0.37.0 // indirect79 golang.org/x/text v0.37.0 // indirect
80 golang.org/x/tools v0.45.0 // indirect80 golang.org/x/tools v0.45.0 // indirect
M go.sum +2 -2 generated file: collapsed
A internal/application/port/favicon_blob_store.go +17 -0
@@ -0,0 +1,17 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/favicon"
7+)
8+
9+type FaviconBlobStore interface {
10+ ReadOriginal(ctx context.Context, key favicon.Key) ([]byte, string, error)
11+ WriteOriginal(ctx context.Context, key favicon.Key, data []byte, contentType string) error
12+ ReadPNG(ctx context.Context, key favicon.Key) ([]byte, string, error)
13+ WritePNG(ctx context.Context, key favicon.Key, data []byte) error
14+ ReadSizedPNG(ctx context.Context, key favicon.Key, size int) ([]byte, string, error)
15+ WriteSizedPNG(ctx context.Context, key favicon.Key, size int, data []byte) error
16+ RemoveDerived(ctx context.Context, key favicon.Key) error
17+}
A internal/application/port/favicon_converter.go +12 -0
@@ -0,0 +1,12 @@
1+package port
2+
3+import "context"
4+
5+type ConvertedFavicon struct {
6+ PNG []byte
7+ SizedPNG map[int][]byte
8+}
9+
10+type FaviconImageConverter interface {
11+ Convert(ctx context.Context, original []byte, contentType string, sizes []int) (*ConvertedFavicon, error)
12+}
A internal/application/port/favicon_discoverer.go +12 -0
@@ -0,0 +1,12 @@
1+package port
2+
3+import "context"
4+
5+type FaviconDiscovery struct {
6+ PageURL string
7+ IconURLs []string
8+}
9+
10+type FaviconDiscoverer interface {
11+ Discover(ctx context.Context, pageURL string) (*FaviconDiscovery, error)
12+}
A internal/application/port/favicon_errors.go +9 -0
@@ -0,0 +1,9 @@
1+package port
2+
3+import "errors"
4+
5+// ErrFaviconMiss is returned when a favicon cannot be found or served from allowed sources.
6+var ErrFaviconMiss = errors.New("favicon miss")
7+
8+// ErrFaviconInvalidInput is returned when favicon storage receives invalid input.
9+var ErrFaviconInvalidInput = errors.New("invalid favicon input")
A internal/application/port/favicon_fetcher.go +30 -0
@@ -0,0 +1,30 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/favicon"
7+)
8+
9+// FaviconFetchRequest specifies the page and optional icon URL for a favicon fetch.
10+// PageURL is the absolute source page URL; IconURL may be empty when the fetcher should discover or fall back.
11+type FaviconFetchRequest struct {
12+ PageURL string
13+ IconURL string
14+}
15+
16+// FaviconFetchedIcon contains fetched favicon bytes and their source metadata.
17+// PageURL is the source page, IconURL is the resource URL, Source identifies discovery origin, and ContentType is the MIME type.
18+type FaviconFetchedIcon struct {
19+ PageURL string
20+ IconURL string
21+ Bytes []byte
22+ Source favicon.Source
23+ ContentType string
24+}
25+
26+// FaviconFetcher safely fetches favicon resources for a FaviconFetchRequest.
27+// Implementations validate URLs/content and return ErrFaviconMiss for not-found or disallowed resources.
28+type FaviconFetcher interface {
29+ Fetch(ctx context.Context, request FaviconFetchRequest) (*FaviconFetchedIcon, error)
30+}
A internal/application/port/favicon_invalidator.go +22 -0
@@ -0,0 +1,22 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/favicon"
7+)
8+
9+// FaviconInvalidator clears cached or derived data for a single favicon key.
10+type FaviconInvalidator interface {
11+ Invalidate(ctx context.Context, key favicon.Key) error
12+}
13+
14+// FaviconInvalidators coordinates invalidation across registered invalidators.
15+type FaviconInvalidators interface {
16+ InvalidateAll(ctx context.Context, key favicon.Key) error
17+}
18+
19+// FaviconInvalidatorRegistry registers invalidators during application initialization.
20+type FaviconInvalidatorRegistry interface {
21+ RegisterFaviconInvalidator(invalidator FaviconInvalidator)
22+}
A internal/application/port/favicon_repository.go +19 -0
@@ -0,0 +1,19 @@
1+package port
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/favicon"
8+)
9+
10+// FaviconRepository persists favicon metadata and supports fallback-key lookups.
11+// Get returns metadata for one key, FindFirst returns the first matching key, Upsert writes metadata,
12+// UpdateLastChecked records refresh state, and Delete removes metadata.
13+type FaviconRepository interface {
14+ Get(ctx context.Context, key favicon.Key) (*favicon.Metadata, error)
15+ FindFirst(ctx context.Context, keys []favicon.Key) (*favicon.Metadata, error)
16+ Upsert(ctx context.Context, meta favicon.Metadata) error
17+ UpdateLastChecked(ctx context.Context, key favicon.Key, contentHash string, checkedAt time.Time) error
18+ Delete(ctx context.Context, key favicon.Key) error
19+}
A internal/application/port/favicon_resolver.go +60 -0
@@ -0,0 +1,60 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/favicon"
7+)
8+
9+// FaviconResolvePurpose identifies the context requesting favicon resolution.
10+type FaviconResolvePurpose string
11+
12+const (
13+ // FaviconResolvePurposeUI resolves favicons for end-user UI display.
14+ FaviconResolvePurposeUI FaviconResolvePurpose = "ui"
15+ // FaviconResolvePurposeSystemview resolves favicons for internal views without remote loads.
16+ FaviconResolvePurposeSystemview FaviconResolvePurpose = "systemview"
17+ // FaviconResolvePurposeRefresh resolves favicons for background refresh work.
18+ FaviconResolvePurposeRefresh FaviconResolvePurpose = "refresh"
19+)
20+
21+// FaviconResolveOptions controls cache lookup, blocking refresh, background refresh, and caller intent.
22+type FaviconResolveOptions struct {
23+ AllowBlockingRefresh bool
24+ ScheduleBackgroundRefresh bool
25+ Purpose FaviconResolvePurpose
26+}
27+
28+// ResolvedFavicon is a fully resolved favicon payload with metadata and refresh state.
29+// Key identifies the cache entry; Bytes and ContentType hold image data; Metadata and BackgroundRefreshScheduled describe state.
30+type ResolvedFavicon struct {
31+ Key favicon.Key
32+ Bytes []byte
33+ ContentType string
34+ Metadata *favicon.Metadata
35+ BackgroundRefreshScheduled bool
36+}
37+
38+// FaviconResolver orchestrates favicon lookup, observation, refresh, sizing, and invalidation.
39+// It extends FaviconSystemviewResolver for boundary-safe systemview reads.
40+type FaviconResolver interface {
41+ FaviconSystemviewResolver
42+ Resolve(ctx context.Context, rawURLOrDomain string, size int, options FaviconResolveOptions) (*ResolvedFavicon, error)
43+ Observe(ctx context.Context, pageURL, iconURL string, bytes []byte, source favicon.Source, contentType string) (*favicon.Metadata, error)
44+ RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string) error
45+ EnsureSized(ctx context.Context, key favicon.Key, size int) error
46+ Invalidate(ctx context.Context, key favicon.Key) error
47+}
48+
49+// FaviconSystemviewResolver is the boundary-safe API used by engine scheme
50+// handlers. Implementations must resolve exact keys and parent fallbacks through
51+// the application usecase and must not trigger remote network loads.
52+type FaviconSystemviewResolver interface {
53+ ResolveSystemviewIcon(ctx context.Context, rawDomain string, size int) (*ResolvedFavicon, error)
54+}
55+
56+// SystemviewFaviconResolverSetter is an optional engine capability that wires
57+// the application favicon resolver to internal systemview pages.
58+type SystemviewFaviconResolverSetter interface {
59+ SetSystemviewFaviconResolver(resolver FaviconSystemviewResolver)
60+}
A internal/application/port/favicon_scheduler.go +13 -0
@@ -0,0 +1,13 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/favicon"
7+)
8+
9+// FaviconRefreshScheduler deduplicates and runs asynchronous favicon refresh work.
10+// Schedule returns true when work is queued and false for duplicate or rejected work.
11+type FaviconRefreshScheduler interface {
12+ Schedule(ctx context.Context, key favicon.Key, work func(context.Context)) bool
13+}
M internal/application/port/favicon_service.go +0 -6
@@ -18,9 +18,3 @@ type FaviconService interface {
18 EnsureDiskCache(ctx context.Context, domain string)18 EnsureDiskCache(ctx context.Context, domain string)
19 Close()19 Close()
20 }20 }
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 {
101 // OnFaviconChanged is called when the page favicon changes.101 // OnFaviconChanged is called when the page favicon changes.
102 // The parameter is a *gdk.Texture (passed as Texture interface to avoid GTK import in port layer).102 // The parameter is a *gdk.Texture (passed as Texture interface to avoid GTK import in port layer).
103 OnFaviconChanged func(favicon Texture)103 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)
104 // OnClose is called when the WebView requests to close.106 // OnClose is called when the WebView requests to close.
105 OnClose func()107 OnClose func()
106 // OnCreate is called when a popup window is requested.108 // OnCreate is called when a popup window is requested.
A internal/application/usecase/favicon.go +401 -0
@@ -0,0 +1,401 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+ "time"
9+
10+ appport "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/favicon"
12+)
13+
14+var ErrFaviconMiss = appport.ErrFaviconMiss
15+
16+type ResolvePurpose = appport.FaviconResolvePurpose
17+
18+const (
19+ ResolvePurposeUI = appport.FaviconResolvePurposeUI
20+ ResolvePurposeSystemview = appport.FaviconResolvePurposeSystemview
21+ ResolvePurposeRefresh = appport.FaviconResolvePurposeRefresh
22+)
23+
24+type ResolveOptions = appport.FaviconResolveOptions
25+type ResolvedFavicon = appport.ResolvedFavicon
26+
27+// SystemviewIconSize is the fixed PNG size served to internal systemview pages.
28+const SystemviewIconSize = 32
29+
30+type faviconInvalidatorRegistry struct {
31+ mu sync.RWMutex
32+ fallback appport.FaviconInvalidators
33+ invalidators []appport.FaviconInvalidator
34+}
35+
36+func (r *faviconInvalidatorRegistry) Register(invalidator appport.FaviconInvalidator) {
37+ if r == nil || invalidator == nil {
38+ return
39+ }
40+ r.mu.Lock()
41+ defer r.mu.Unlock()
42+ r.invalidators = append(r.invalidators, invalidator)
43+}
44+
45+func (r *faviconInvalidatorRegistry) InvalidateAll(ctx context.Context, key favicon.Key) error {
46+ if r == nil {
47+ return nil
48+ }
49+ r.mu.RLock()
50+ fallback := r.fallback
51+ invalidators := append([]appport.FaviconInvalidator(nil), r.invalidators...)
52+ r.mu.RUnlock()
53+ // The fallback invalidator owns pre-registered invalidation sets and is
54+ // treated as fatal; dynamic invalidators below are best-effort and joined.
55+ if fallback != nil {
56+ if err := fallback.InvalidateAll(ctx, key); err != nil {
57+ return err
58+ }
59+ }
60+ var errs []error
61+ for _, invalidator := range invalidators {
62+ if err := invalidator.Invalidate(ctx, key); err != nil {
63+ errs = append(errs, err)
64+ }
65+ }
66+ return errors.Join(errs...)
67+}
68+
69+type FaviconUseCase struct {
70+ repo appport.FaviconRepository
71+ blobs appport.FaviconBlobStore
72+ fetcher appport.FaviconFetcher
73+ discoverer appport.FaviconDiscoverer
74+ converter appport.FaviconImageConverter
75+ scheduler appport.FaviconRefreshScheduler
76+ invalidators *faviconInvalidatorRegistry
77+ now func() time.Time
78+ ttl time.Duration
79+ background context.Context
80+}
81+
82+type FaviconDeps struct {
83+ Repository appport.FaviconRepository
84+ BlobStore appport.FaviconBlobStore
85+ Fetcher appport.FaviconFetcher
86+ Discoverer appport.FaviconDiscoverer
87+ Converter appport.FaviconImageConverter
88+ Scheduler appport.FaviconRefreshScheduler
89+ Invalidators appport.FaviconInvalidators
90+ Now func() time.Time
91+ TTL time.Duration
92+ Background context.Context
93+}
94+
95+func NewFaviconUseCase(deps FaviconDeps) *FaviconUseCase {
96+ if deps.Now == nil {
97+ deps.Now = time.Now
98+ }
99+ if deps.TTL == 0 {
100+ deps.TTL = favicon.DefaultTTL
101+ }
102+ if deps.Background == nil {
103+ deps.Background = context.Background()
104+ }
105+ registry := &faviconInvalidatorRegistry{}
106+ if deps.Invalidators != nil {
107+ registry.fallback = deps.Invalidators
108+ }
109+ return &FaviconUseCase{
110+ repo: deps.Repository,
111+ blobs: deps.BlobStore,
112+ fetcher: deps.Fetcher,
113+ discoverer: deps.Discoverer,
114+ converter: deps.Converter,
115+ scheduler: deps.Scheduler,
116+ invalidators: registry,
117+ now: deps.Now,
118+ ttl: deps.TTL,
119+ background: deps.Background,
120+ }
121+}
122+
123+func (uc *FaviconUseCase) RegisterFaviconInvalidator(invalidator appport.FaviconInvalidator) {
124+ if uc == nil || uc.invalidators == nil || invalidator == nil {
125+ return
126+ }
127+ uc.invalidators.Register(invalidator)
128+}
129+
130+func (uc *FaviconUseCase) Resolve(
131+ ctx context.Context,
132+ rawURLOrDomain string,
133+ size int,
134+ options ResolveOptions,
135+) (*appport.ResolvedFavicon, error) {
136+ keys := favicon.Candidates(rawURLOrDomain)
137+ if len(keys) == 0 {
138+ return nil, ErrFaviconMiss
139+ }
140+
141+ for _, key := range keys {
142+ if err := ctx.Err(); err != nil {
143+ return nil, err
144+ }
145+ meta, err := uc.repo.Get(ctx, key)
146+ if err != nil {
147+ return nil, err
148+ }
149+ if meta == nil {
150+ continue
151+ }
152+ bytes, contentType, err := uc.readForSize(ctx, key, size)
153+ if err != nil {
154+ if !errors.Is(err, ErrFaviconMiss) {
155+ return nil, err
156+ }
157+ continue
158+ }
159+ res := &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: meta}
160+ if favicon.ShouldRefresh(meta, uc.now(), uc.ttl) && options.ScheduleBackgroundRefresh {
161+ res.BackgroundRefreshScheduled = uc.scheduleRefresh(key, meta.PageURL)
162+ }
163+ return res, nil
164+ }
165+
166+ if options.AllowBlockingRefresh {
167+ if err := uc.refresh(ctx, rawURLOrDomain, true); err != nil {
168+ return nil, err
169+ }
170+ return uc.Resolve(ctx, rawURLOrDomain, size, ResolveOptions{Purpose: options.Purpose})
171+ }
172+ if options.ScheduleBackgroundRefresh {
173+ uc.scheduleRefresh(keys[0], rawURLOrDomain)
174+ }
175+ return nil, ErrFaviconMiss
176+}
177+
178+func (uc *FaviconUseCase) ResolveSystemviewIcon(ctx context.Context, rawDomain string, size int) (*appport.ResolvedFavicon, error) {
179+ if size != SystemviewIconSize {
180+ return nil, ErrFaviconMiss
181+ }
182+ return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview})
183+}
184+
185+func (uc *FaviconUseCase) Observe(
186+ ctx context.Context,
187+ pageURL, iconURL string,
188+ bytes []byte,
189+ source favicon.Source,
190+ contentType string,
191+) (*favicon.Metadata, error) {
192+ key, ok := favicon.CanonicalKey(pageURL)
193+ if !ok || len(bytes) == 0 {
194+ return nil, ErrFaviconMiss
195+ }
196+ old, err := uc.repo.Get(ctx, key)
197+ if err != nil {
198+ return nil, err
199+ }
200+ now := uc.now()
201+ if !favicon.HasContentChanged(old, bytes) {
202+ if err := uc.repo.UpdateLastChecked(ctx, key, old.ContentHash, now); err != nil {
203+ return nil, err
204+ }
205+ old.LastCheckedAt = now
206+ return old, nil
207+ }
208+
209+ var converted *appport.ConvertedFavicon
210+ if uc.converter != nil {
211+ var err error
212+ converted, err = uc.converter.Convert(ctx, bytes, contentType, []int{32})
213+ if err != nil {
214+ return nil, err
215+ }
216+ }
217+
218+ if err := uc.blobs.WriteOriginal(ctx, key, bytes, contentType); err != nil {
219+ return nil, err
220+ }
221+ if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
222+ return nil, err
223+ }
224+ if converted != nil {
225+ if len(converted.PNG) > 0 {
226+ if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
227+ return nil, err
228+ }
229+ }
230+ if b := converted.SizedPNG[32]; len(b) > 0 {
231+ if err := uc.blobs.WriteSizedPNG(ctx, key, 32, b); err != nil {
232+ return nil, err
233+ }
234+ }
235+ }
236+ meta := favicon.Metadata{
237+ Key: key,
238+ SourceURL: iconURL,
239+ PageURL: pageURL,
240+ Source: source,
241+ ContentHash: favicon.Hash(bytes),
242+ ContentType: contentType,
243+ UpdatedAt: now,
244+ LastCheckedAt: now,
245+ }
246+ if err := uc.repo.Upsert(ctx, meta); err != nil {
247+ return nil, err
248+ }
249+ if err := uc.invalidateCaches(ctx, key); err != nil {
250+ return nil, err
251+ }
252+ return &meta, nil
253+}
254+
255+func (uc *FaviconUseCase) RefreshIfStale(ctx context.Context, pageURL string) error {
256+ return uc.refresh(ctx, pageURL, false)
257+}
258+
259+func (uc *FaviconUseCase) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string) error {
260+ if uc.fetcher == nil {
261+ return ErrFaviconMiss
262+ }
263+ var lastMiss error
264+ for _, iconURL := range iconURLs {
265+ if err := ctx.Err(); err != nil {
266+ return err
267+ }
268+ fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL, IconURL: iconURL})
269+ if errors.Is(err, ErrFaviconMiss) {
270+ lastMiss = err
271+ continue
272+ }
273+ if err != nil {
274+ return err
275+ }
276+ _, err = uc.Observe(ctx, fetched.PageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
277+ if errors.Is(err, ErrFaviconMiss) {
278+ lastMiss = err
279+ continue
280+ }
281+ return err
282+ }
283+ if lastMiss != nil {
284+ return lastMiss
285+ }
286+ return uc.RefreshIfStale(ctx, pageURL)
287+}
288+
289+func (uc *FaviconUseCase) refresh(ctx context.Context, pageURL string, force bool) error {
290+ keys := favicon.Candidates(pageURL)
291+ if len(keys) == 0 {
292+ return ErrFaviconMiss
293+ }
294+ if !force {
295+ fresh, err := uc.anyCandidateFresh(ctx, keys)
296+ if err != nil {
297+ return err
298+ }
299+ if fresh {
300+ return nil
301+ }
302+ }
303+ return uc.fetchAndObserve(ctx, pageURL)
304+}
305+
306+func (uc *FaviconUseCase) refreshKey(ctx context.Context, key favicon.Key, pageURL string) error {
307+ meta, err := uc.repo.Get(ctx, key)
308+ if err != nil {
309+ return err
310+ }
311+ if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
312+ return nil
313+ }
314+ return uc.fetchAndObserve(ctx, pageURL)
315+}
316+
317+func (uc *FaviconUseCase) anyCandidateFresh(ctx context.Context, keys []favicon.Key) (bool, error) {
318+ for _, key := range keys {
319+ meta, err := uc.repo.Get(ctx, key)
320+ if err != nil {
321+ return false, err
322+ }
323+ if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
324+ return true, nil
325+ }
326+ }
327+ return false, nil
328+}
329+
330+func (uc *FaviconUseCase) fetchAndObserve(ctx context.Context, pageURL string) error {
331+ if uc.fetcher == nil {
332+ return ErrFaviconMiss
333+ }
334+ fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL})
335+ if err != nil {
336+ return err
337+ }
338+ _, err = uc.Observe(ctx, fetched.PageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
339+ return err
340+}
341+
342+func (uc *FaviconUseCase) EnsureSized(ctx context.Context, key favicon.Key, size int) error {
343+ if _, _, err := uc.blobs.ReadSizedPNG(ctx, key, size); err == nil {
344+ return nil
345+ } else if !errors.Is(err, ErrFaviconMiss) {
346+ return err
347+ }
348+ orig, ct, err := uc.blobs.ReadOriginal(ctx, key)
349+ if err != nil {
350+ return err
351+ }
352+ converted, err := uc.converter.Convert(ctx, orig, ct, []int{size})
353+ if err != nil {
354+ return err
355+ }
356+ if len(converted.PNG) > 0 {
357+ if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
358+ return err
359+ }
360+ }
361+ if b := converted.SizedPNG[size]; len(b) > 0 {
362+ return uc.blobs.WriteSizedPNG(ctx, key, size, b)
363+ }
364+ return ErrFaviconMiss
365+}
366+
367+func (uc *FaviconUseCase) Invalidate(ctx context.Context, key favicon.Key) error {
368+ if uc.blobs != nil {
369+ if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
370+ return err
371+ }
372+ }
373+ return uc.invalidateCaches(ctx, key)
374+}
375+
376+func (uc *FaviconUseCase) invalidateCaches(ctx context.Context, key favicon.Key) error {
377+ if uc.invalidators != nil {
378+ return uc.invalidators.InvalidateAll(ctx, key)
379+ }
380+ return nil
381+}
382+
383+func (uc *FaviconUseCase) readForSize(ctx context.Context, key favicon.Key, size int) ([]byte, string, error) {
384+ if size < 0 {
385+ return nil, "", fmt.Errorf("invalid favicon size: %d", size)
386+ }
387+ if size > 0 {
388+ if err := uc.EnsureSized(ctx, key, size); err != nil {
389+ return nil, "", err
390+ }
391+ return uc.blobs.ReadSizedPNG(ctx, key, size)
392+ }
393+ return uc.blobs.ReadPNG(ctx, key)
394+}
395+
396+func (uc *FaviconUseCase) scheduleRefresh(key favicon.Key, pageURL string) bool {
397+ if uc.scheduler == nil {
398+ return false
399+ }
400+ return uc.scheduler.Schedule(uc.background, key, func(ctx context.Context) { _ = uc.refreshKey(ctx, key, pageURL) })
401+}
A internal/application/usecase/favicon_test.go +499 -0
@@ -0,0 +1,499 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ appport "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/favicon"
11+)
12+
13+func TestFaviconResolveExactHit(t *testing.T) {
14+ fx := newFaviconFixture()
15+ fx.seed("docs.example.com", []byte("png32"), false)
16+ got, err := fx.uc.Resolve(context.Background(), "https://docs.example.com/a", 32, ResolveOptions{Purpose: ResolvePurposeUI})
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if got.Key != "docs.example.com" || string(got.Bytes) != "png32" {
21+ t.Fatalf("unexpected resolve: %#v %q", got, got.Bytes)
22+ }
23+}
24+
25+func TestFaviconResolveParentFallback(t *testing.T) {
26+ fx := newFaviconFixture()
27+ fx.seed("example.com", []byte("parent"), false)
28+ got, err := fx.uc.Resolve(context.Background(), "https://docs.example.com/a", 32, ResolveOptions{Purpose: ResolvePurposeUI})
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ if got.Key != "example.com" || string(got.Bytes) != "parent" {
33+ t.Fatalf("unexpected fallback: %#v", got)
34+ }
35+}
36+
37+func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) {
38+ fx := newFaviconFixture()
39+ fx.seed("example.com", []byte("old"), true)
40+ for i := 0; i < 2; i++ {
41+ got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{ScheduleBackgroundRefresh: true})
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ if !got.BackgroundRefreshScheduled && i == 0 {
46+ t.Fatal("first stale resolve did not schedule refresh")
47+ }
48+ }
49+ if fx.scheduler.schedules != 1 {
50+ t.Fatalf("schedules=%d", fx.scheduler.schedules)
51+ }
52+ if fx.scheduler.usedCallerContext {
53+ t.Fatal("scheduler used caller context")
54+ }
55+}
56+
57+func TestFaviconResolveStaleExactRefreshIgnoresFreshParent(t *testing.T) {
58+ fx := newFaviconFixture()
59+ fx.seed("docs.example.com", []byte("old-exact"), true)
60+ fx.seed("example.com", []byte("fresh-parent"), false)
61+ got, err := fx.uc.Resolve(
62+ context.Background(),
63+ "https://docs.example.com/page",
64+ 32,
65+ ResolveOptions{ScheduleBackgroundRefresh: true},
66+ )
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ if got.Key != "docs.example.com" || !got.BackgroundRefreshScheduled {
71+ t.Fatalf("unexpected resolve: %#v", got)
72+ }
73+ fx.scheduler.run("docs.example.com")
74+ if string(fx.blobs.original["docs.example.com"]) != "fetched" {
75+ t.Fatalf("exact key was not refreshed, originals=%v", fx.blobs.original)
76+ }
77+}
78+
79+func TestFaviconObserveSameHashUpdatesMetadataOnly(t *testing.T) {
80+ fx := newFaviconFixture()
81+ fx.seedOriginal([]byte("same"))
82+ if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://cdn.test/icon.png", []byte("same"), favicon.SourceEngine, "image/png"); err != nil {
83+ t.Fatal(err)
84+ }
85+ if fx.blobs.writeOriginals != 0 || fx.converter.calls != 0 || fx.invalidators.calls != 0 {
86+ t.Fatalf("rewrote/converted/invalidated same hash")
87+ }
88+ if fx.repo.byKey["example.com"].LastCheckedAt != fx.now {
89+ t.Fatal("last checked not updated")
90+ }
91+}
92+
93+func TestFaviconObserveChangedHashRegeneratesAndInvalidates(t *testing.T) {
94+ fx := newFaviconFixture()
95+ fx.seedOriginal([]byte("old"))
96+ if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://cdn.test/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); err != nil {
97+ t.Fatal(err)
98+ }
99+ if fx.blobs.writeOriginals != 1 || fx.converter.calls != 1 || fx.blobs.removeDerived != 1 || fx.invalidators.calls != 1 {
100+ t.Fatalf("writes=%d converts=%d remove=%d invalidates=%d", fx.blobs.writeOriginals, fx.converter.calls, fx.blobs.removeDerived, fx.invalidators.calls)
101+ }
102+ if string(fx.blobs.png["example.com"]) != "png:new" || string(fx.blobs.sized["example.com"][32]) != "png32:new" {
103+ t.Fatal("derived pngs not regenerated")
104+ }
105+ if fx.repo.byKey["example.com"].SourceURL != "https://cdn.test/icon.png" {
106+ t.Fatal("iconURL not stored as metadata")
107+ }
108+}
109+
110+func TestFaviconObserveWithoutConverterStoresOriginalOnly(t *testing.T) {
111+ fx := newFaviconFixture()
112+ fx.uc.converter = nil
113+ if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://example.com/icon.png", []byte("raw"), favicon.SourceEngine, "image/png"); err != nil {
114+ t.Fatal(err)
115+ }
116+ if string(fx.blobs.original["example.com"]) != "raw" {
117+ t.Fatalf("original not stored: %v", fx.blobs.original)
118+ }
119+ if len(fx.blobs.png) != 0 || len(fx.blobs.sized) != 0 {
120+ t.Fatalf("unexpected derivatives: png=%v sized=%v", fx.blobs.png, fx.blobs.sized)
121+ }
122+}
123+
124+func TestFaviconResolveMissingFetchesOnlyWhenBlockingAllowed(t *testing.T) {
125+ fx := newFaviconFixture()
126+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, ErrFaviconMiss) {
127+ t.Fatalf("default resolve err=%v", err)
128+ }
129+ if fx.fetcher.calls != 0 {
130+ t.Fatal("default resolve blocked on fetch")
131+ }
132+ got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true})
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ if got.Key != "example.com" || fx.fetcher.calls != 1 {
137+ t.Fatalf("blocking resolve failed: %#v calls=%d", got, fx.fetcher.calls)
138+ }
139+}
140+
141+func TestFaviconRefreshIfStaleUsesParentFallbackBeforeFetching(t *testing.T) {
142+ fx := newFaviconFixture()
143+ fx.seed("example.com", []byte("parent"), false)
144+ if err := fx.uc.RefreshIfStale(context.Background(), "https://docs.example.com/page"); err != nil {
145+ t.Fatal(err)
146+ }
147+ if fx.fetcher.calls != 0 {
148+ t.Fatalf("fresh parent fallback should not fetch, calls=%d", fx.fetcher.calls)
149+ }
150+}
151+
152+func TestFaviconUseCasePropagatesRepositoryErrors(t *testing.T) {
153+ fx := newFaviconFixture()
154+ fx.repo.err = errors.New("database unavailable")
155+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, fx.repo.err) {
156+ t.Fatalf("Resolve err=%v, want %v", err, fx.repo.err)
157+ }
158+ if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://cdn.example/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); !errors.Is(err, fx.repo.err) {
159+ t.Fatalf("Observe err=%v, want %v", err, fx.repo.err)
160+ }
161+ if err := fx.uc.RefreshIfStale(context.Background(), "https://example.com"); !errors.Is(err, fx.repo.err) {
162+ t.Fatalf("RefreshIfStale err=%v, want %v", err, fx.repo.err)
163+ }
164+}
165+
166+func TestFaviconResolvePropagatesBlobErrors(t *testing.T) {
167+ fx := newFaviconFixture()
168+ fx.seed("example.com", []byte("cached"), false)
169+ fx.blobs.err = errors.New("disk unavailable")
170+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, fx.blobs.err) {
171+ t.Fatalf("Resolve err=%v, want %v", err, fx.blobs.err)
172+ }
173+}
174+
175+func TestFaviconResolveBlockingRefreshesWhenMetadataFreshButBlobMissing(t *testing.T) {
176+ fx := newFaviconFixture()
177+ fx.repo.byKey["example.com"] = &favicon.Metadata{Key: "example.com", PageURL: "https://example.com", ContentHash: favicon.Hash([]byte("old")), LastCheckedAt: fx.now}
178+ got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true})
179+ if err != nil {
180+ t.Fatal(err)
181+ }
182+ if got.Key != "example.com" || fx.fetcher.calls != 1 || string(got.Bytes) != "png32:fetched" {
183+ t.Fatalf("unexpected resolve after forced refresh: got=%#v calls=%d bytes=%q", got, fx.fetcher.calls, got.Bytes)
184+ }
185+}
186+
187+func TestFaviconResolveBlockingPropagatesFetchErrors(t *testing.T) {
188+ fx := newFaviconFixture()
189+ fx.fetcher.err = errors.New("network unavailable")
190+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true}); !errors.Is(err, fx.fetcher.err) {
191+ t.Fatalf("Resolve err=%v, want %v", err, fx.fetcher.err)
192+ }
193+}
194+
195+func TestFaviconRefreshFromIconURLsUsesPageURLKey(t *testing.T) {
196+ fx := newFaviconFixture()
197+ if err := fx.uc.RefreshFromIconURLs(context.Background(), "https://docs.example.com/page", []string{"https://cdn.example.net/icon.png"}); err != nil {
198+ t.Fatal(err)
199+ }
200+ if _, ok := fx.repo.byKey["docs.example.com"]; !ok {
201+ t.Fatalf("page URL key was not stored; keys=%v", fx.repo.byKey)
202+ }
203+ if _, ok := fx.repo.byKey["cdn.example.net"]; ok {
204+ t.Fatal("icon URL host should not be used as cache key")
205+ }
206+}
207+
208+func TestFaviconObserveConversionErrorDoesNotMutateExistingAssets(t *testing.T) {
209+ fx := newFaviconFixture()
210+ fx.seedOriginal([]byte("old"))
211+ fx.blobs.png["example.com"] = []byte("old-png")
212+ fx.blobs.sized["example.com"] = map[int][]byte{32: []byte("old-32")}
213+ fx.converter.err = errors.New("unsupported image")
214+
215+ if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://example.com/favicon.ico", []byte("bad"), favicon.SourceEngine, "image/svg+xml"); !errors.Is(err, fx.converter.err) {
216+ t.Fatalf("Observe err=%v, want %v", err, fx.converter.err)
217+ }
218+ if string(fx.blobs.original["example.com"]) != "old" || string(fx.blobs.png["example.com"]) != "old-png" || string(fx.blobs.sized["example.com"][32]) != "old-32" {
219+ t.Fatalf("existing assets mutated on conversion error")
220+ }
221+ if fx.blobs.writeOriginals != 0 || fx.blobs.removeDerived != 0 || fx.invalidators.calls != 0 {
222+ t.Fatalf("unexpected mutation counters writes=%d remove=%d invalidates=%d", fx.blobs.writeOriginals, fx.blobs.removeDerived, fx.invalidators.calls)
223+ }
224+}
225+
226+func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) {
227+ fx := newFaviconFixture()
228+ fx.seedOriginal([]byte("old"))
229+ fx.blobs.readSizedErr = errors.New("sized store unavailable")
230+ if err := fx.uc.EnsureSized(context.Background(), "example.com", 32); !errors.Is(err, fx.blobs.readSizedErr) {
231+ t.Fatalf("EnsureSized err=%v, want %v", err, fx.blobs.readSizedErr)
232+ }
233+ if fx.converter.calls != 0 {
234+ t.Fatal("converter called after non-miss ReadSizedPNG error")
235+ }
236+}
237+
238+type faviconFixture struct {
239+ repo *fakeFaviconRepo
240+ blobs *fakeBlobStore
241+ converter *fakeConverter
242+ scheduler *fakeScheduler
243+ invalidators *fakeInvalidators
244+ fetcher *fakeFetcher
245+ uc *FaviconUseCase
246+ now time.Time
247+}
248+
249+type faviconTestContextKey string
250+
251+const faviconFixtureBackgroundKey faviconTestContextKey = "bg"
252+
253+func newFaviconFixture() *faviconFixture {
254+ now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
255+ fx := &faviconFixture{
256+ repo: newFakeRepo(),
257+ blobs: newFakeBlobStore(),
258+ converter: &fakeConverter{},
259+ scheduler: &fakeScheduler{seen: map[favicon.Key]bool{}},
260+ invalidators: &fakeInvalidators{},
261+ fetcher: &fakeFetcher{},
262+ now: now,
263+ }
264+ fx.uc = NewFaviconUseCase(FaviconDeps{
265+ Repository: fx.repo,
266+ BlobStore: fx.blobs,
267+ Converter: fx.converter,
268+ Scheduler: fx.scheduler,
269+ Invalidators: fx.invalidators,
270+ Fetcher: fx.fetcher,
271+ Now: func() time.Time { return fx.now },
272+ Background: context.WithValue(context.Background(), faviconFixtureBackgroundKey, true),
273+ })
274+ return fx
275+}
276+func (f *faviconFixture) seed(key string, sized []byte, stale bool) {
277+ checked := f.now
278+ if stale {
279+ checked = f.now.Add(-favicon.DefaultTTL - time.Hour)
280+ }
281+ f.repo.byKey[favicon.Key(key)] = &favicon.Metadata{Key: favicon.Key(key), PageURL: "https://" + key, ContentHash: favicon.Hash([]byte("orig")), LastCheckedAt: checked}
282+ f.blobs.sized[favicon.Key(key)] = map[int][]byte{32: sized}
283+}
284+func (f *faviconFixture) seedOriginal(b []byte) {
285+ const contentType = "image/png"
286+ key := favicon.Key("example.com")
287+ f.repo.byKey[key] = &favicon.Metadata{
288+ Key: key,
289+ PageURL: "https://example.com",
290+ ContentHash: favicon.Hash(b),
291+ ContentType: contentType,
292+ LastCheckedAt: f.now.Add(-time.Hour),
293+ }
294+ f.blobs.original[key] = b
295+ f.blobs.contentTypes[key] = contentType
296+}
297+
298+type fakeFaviconRepo struct {
299+ byKey map[favicon.Key]*favicon.Metadata
300+ err error
301+}
302+
303+func newFakeRepo() *fakeFaviconRepo {
304+ return &fakeFaviconRepo{byKey: map[favicon.Key]*favicon.Metadata{}}
305+}
306+func (r *fakeFaviconRepo) Get(_ context.Context, key favicon.Key) (*favicon.Metadata, error) {
307+ if r.err != nil {
308+ return nil, r.err
309+ }
310+ if m := r.byKey[key]; m != nil {
311+ cp := *m
312+ return &cp, nil
313+ }
314+ return nil, nil
315+}
316+func (r *fakeFaviconRepo) FindFirst(ctx context.Context, keys []favicon.Key) (*favicon.Metadata, error) {
317+ if r.err != nil {
318+ return nil, r.err
319+ }
320+ for _, k := range keys {
321+ if m, _ := r.Get(ctx, k); m != nil {
322+ return m, nil
323+ }
324+ }
325+ return nil, nil
326+}
327+func (r *fakeFaviconRepo) Upsert(_ context.Context, meta favicon.Metadata) error {
328+ if r.err != nil {
329+ return r.err
330+ }
331+ cp := meta
332+ r.byKey[meta.Key] = &cp
333+ return nil
334+}
335+func (r *fakeFaviconRepo) UpdateLastChecked(_ context.Context, key favicon.Key, hash string, checkedAt time.Time) error {
336+ if r.err != nil {
337+ return r.err
338+ }
339+ r.byKey[key].ContentHash = hash
340+ r.byKey[key].LastCheckedAt = checkedAt
341+ return nil
342+}
343+func (r *fakeFaviconRepo) Delete(_ context.Context, key favicon.Key) error {
344+ if r.err != nil {
345+ return r.err
346+ }
347+ delete(r.byKey, key)
348+ return nil
349+}
350+
351+type fakeBlobStore struct {
352+ original, png map[favicon.Key][]byte
353+ sized map[favicon.Key]map[int][]byte
354+ contentTypes map[favicon.Key]string
355+ writeOriginals, removeDerived int
356+ err error
357+ readSizedErr error
358+}
359+
360+func newFakeBlobStore() *fakeBlobStore {
361+ return &fakeBlobStore{original: map[favicon.Key][]byte{}, png: map[favicon.Key][]byte{}, sized: map[favicon.Key]map[int][]byte{}, contentTypes: map[favicon.Key]string{}}
362+}
363+func (b *fakeBlobStore) ReadOriginal(_ context.Context, key favicon.Key) ([]byte, string, error) {
364+ if b.err != nil {
365+ return nil, "", b.err
366+ }
367+ v := b.original[key]
368+ if v == nil {
369+ return nil, "", ErrFaviconMiss
370+ }
371+ return v, b.contentTypes[key], nil
372+}
373+func (b *fakeBlobStore) WriteOriginal(_ context.Context, key favicon.Key, data []byte, ct string) error {
374+ if b.err != nil {
375+ return b.err
376+ }
377+ b.writeOriginals++
378+ b.original[key] = data
379+ b.contentTypes[key] = ct
380+ return nil
381+}
382+func (b *fakeBlobStore) ReadPNG(_ context.Context, key favicon.Key) ([]byte, string, error) {
383+ if b.err != nil {
384+ return nil, "", b.err
385+ }
386+ v := b.png[key]
387+ if v == nil {
388+ return nil, "", ErrFaviconMiss
389+ }
390+ return v, "image/png", nil
391+}
392+func (b *fakeBlobStore) WritePNG(_ context.Context, key favicon.Key, data []byte) error {
393+ if b.err != nil {
394+ return b.err
395+ }
396+ b.png[key] = data
397+ return nil
398+}
399+func (b *fakeBlobStore) ReadSizedPNG(_ context.Context, key favicon.Key, size int) ([]byte, string, error) {
400+ if b.err != nil {
401+ return nil, "", b.err
402+ }
403+ if b.readSizedErr != nil {
404+ return nil, "", b.readSizedErr
405+ }
406+ if b.sized[key] == nil || b.sized[key][size] == nil {
407+ return nil, "", ErrFaviconMiss
408+ }
409+ return b.sized[key][size], "image/png", nil
410+}
411+func (b *fakeBlobStore) WriteSizedPNG(_ context.Context, key favicon.Key, size int, data []byte) error {
412+ if b.err != nil {
413+ return b.err
414+ }
415+ if b.sized[key] == nil {
416+ b.sized[key] = map[int][]byte{}
417+ }
418+ b.sized[key][size] = data
419+ return nil
420+}
421+func (b *fakeBlobStore) RemoveDerived(_ context.Context, key favicon.Key) error {
422+ if b.err != nil {
423+ return b.err
424+ }
425+ b.removeDerived++
426+ delete(b.png, key)
427+ delete(b.sized, key)
428+ return nil
429+}
430+
431+type fakeConverter struct {
432+ calls int
433+ err error
434+}
435+
436+func (c *fakeConverter) Convert(_ context.Context, original []byte, _ string, sizes []int) (*appport.ConvertedFavicon, error) {
437+ c.calls++
438+ if c.err != nil {
439+ return nil, c.err
440+ }
441+ out := &appport.ConvertedFavicon{PNG: append([]byte("png:"), original...), SizedPNG: map[int][]byte{}}
442+ for _, s := range sizes {
443+ out.SizedPNG[s] = append([]byte("png32:"), original...)
444+ }
445+ return out, nil
446+}
447+
448+type fakeScheduler struct {
449+ seen map[favicon.Key]bool
450+ works map[favicon.Key]func(context.Context)
451+ schedules int
452+ usedCallerContext bool
453+}
454+
455+func (s *fakeScheduler) Schedule(ctx context.Context, key favicon.Key, work func(context.Context)) bool {
456+ if ctx.Value(faviconFixtureBackgroundKey) != true {
457+ s.usedCallerContext = true
458+ }
459+ if s.seen[key] {
460+ return false
461+ }
462+ if s.works == nil {
463+ s.works = map[favicon.Key]func(context.Context){}
464+ }
465+ s.seen[key] = true
466+ s.works[key] = work
467+ s.schedules++
468+ return true
469+}
470+
471+func (s *fakeScheduler) run(key favicon.Key) {
472+ if work := s.works[key]; work != nil {
473+ work(context.Background())
474+ }
475+}
476+
477+type fakeInvalidators struct{ calls int }
478+
479+func (i *fakeInvalidators) InvalidateAll(_ context.Context, _ favicon.Key) error {
480+ i.calls++
481+ return nil
482+}
483+
484+type fakeFetcher struct {
485+ calls int
486+ err error
487+}
488+
489+func (f *fakeFetcher) Fetch(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
490+ f.calls++
491+ if f.err != nil {
492+ return nil, f.err
493+ }
494+ iconURL := req.IconURL
495+ if iconURL == "" {
496+ iconURL = "https://example.com/favicon.ico"
497+ }
498+ return &appport.FaviconFetchedIcon{PageURL: req.PageURL, IconURL: iconURL, Bytes: []byte("fetched"), Source: favicon.SourceDuckDuckGo, ContentType: "image/png"}, nil
499+}
A internal/domain/favicon/key.go +132 -0
@@ -0,0 +1,132 @@
1+package favicon
2+
3+import (
4+ "net"
5+ "net/url"
6+ "slices"
7+ "strings"
8+
9+ "golang.org/x/net/idna"
10+ "golang.org/x/net/publicsuffix"
11+)
12+
13+type Key string
14+
15+func CanonicalKey(raw string) (Key, bool) {
16+ candidates := Candidates(raw)
17+ if len(candidates) == 0 {
18+ return "", false
19+ }
20+ return candidates[0], true
21+}
22+
23+func Candidates(raw string) []Key {
24+ host, port, hasScheme, scheme, ok := parseHost(raw)
25+ if !ok {
26+ return nil
27+ }
28+
29+ host = normalizeHost(host)
30+ if host == "" {
31+ return nil
32+ }
33+
34+ if isExactHost(host) {
35+ if port != "" && (!hasScheme || !isDefaultPort(scheme, port)) {
36+ return []Key{Key(net.JoinHostPort(host, port))}
37+ }
38+ return []Key{Key(host)}
39+ }
40+
41+ ascii, err := idna.Lookup.ToASCII(host)
42+ if err != nil {
43+ return nil
44+ }
45+ host = strings.ToLower(strings.TrimSuffix(ascii, "."))
46+ host = stripWWW(host)
47+
48+ if port != "" {
49+ if !hasScheme || !isDefaultPort(scheme, port) {
50+ return []Key{Key(net.JoinHostPort(host, port))}
51+ }
52+ }
53+
54+ labels := strings.Split(host, ".")
55+ if slices.Contains(labels, "") {
56+ return nil
57+ }
58+
59+ registrable, err := publicsuffix.EffectiveTLDPlusOne(host)
60+ if err != nil {
61+ return []Key{Key(host)}
62+ }
63+
64+ var out []Key
65+ for i := range len(labels) {
66+ candidate := strings.Join(labels[i:], ".")
67+ out = append(out, Key(candidate))
68+ if candidate == registrable {
69+ break
70+ }
71+ }
72+ return out
73+}
74+
75+func parseHost(raw string) (host, port string, hasScheme bool, scheme string, ok bool) {
76+ raw = strings.TrimSpace(raw)
77+ if raw == "" {
78+ return "", "", false, "", false
79+ }
80+
81+ if strings.Contains(raw, "://") {
82+ u, err := url.Parse(raw)
83+ if err != nil || u.Scheme == "" {
84+ return "", "", false, "", false
85+ }
86+ scheme = strings.ToLower(u.Scheme)
87+ if scheme != "http" && scheme != "https" {
88+ return "", "", true, scheme, false
89+ }
90+ host = u.Hostname()
91+ port = u.Port()
92+ return host, port, true, scheme, host != ""
93+ }
94+ if strings.HasPrefix(strings.ToLower(raw), "about:") {
95+ return "", "", false, "", false
96+ }
97+
98+ hostport := raw
99+ if cut := strings.IndexAny(hostport, "/?#"); cut >= 0 {
100+ hostport = hostport[:cut]
101+ }
102+ hostport = strings.TrimSuffix(hostport, ".")
103+
104+ if h, p, err := net.SplitHostPort(hostport); err == nil {
105+ return h, p, false, "", h != ""
106+ }
107+ if strings.Count(hostport, ":") == 1 {
108+ if h, p, ok := strings.Cut(hostport, ":"); ok && h != "" && p != "" {
109+ return h, p, false, "", true
110+ }
111+ }
112+ return hostport, "", false, "", hostport != ""
113+}
114+
115+func normalizeHost(host string) string {
116+ host = strings.TrimSpace(host)
117+ host = strings.Trim(host, "[]")
118+ host = strings.TrimSuffix(host, ".")
119+ return strings.ToLower(host)
120+}
121+
122+func stripWWW(host string) string {
123+ return strings.TrimPrefix(host, "www.")
124+}
125+
126+func isDefaultPort(scheme, port string) bool {
127+ return (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
128+}
129+
130+func isExactHost(host string) bool {
131+ return host == "localhost" || net.ParseIP(host) != nil
132+}
A internal/domain/favicon/key_test.go +57 -0
@@ -0,0 +1,57 @@
1+package favicon
2+
3+import "testing"
4+
5+func TestKeyCandidates(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ raw string
9+ want []Key
10+ }{
11+ {name: "walks public host to registrable domain", raw: "https://app.docs.example.com/path", want: []Key{"app.docs.example.com", "docs.example.com", "example.com"}},
12+ {name: "strips www", raw: "https://www.example.com", want: []Key{"example.com"}},
13+ {name: "localhost with port is exact", raw: "http://localhost:1455/path", want: []Key{"localhost:1455"}},
14+ {name: "ip with port is exact", raw: "http://127.0.0.1:8080/path", want: []Key{"127.0.0.1:8080"}},
15+ {name: "ipv6 with port is exact", raw: "http://[::1]:8080/path", want: []Key{"[::1]:8080"}},
16+ {name: "uses public suffix for co uk", raw: "https://news.bbc.co.uk/story", want: []Key{"news.bbc.co.uk", "bbc.co.uk"}},
17+
18+ {name: "drops default https port", raw: "https://example.com:443/path", want: []Key{"example.com"}},
19+ {name: "non default url port is exact", raw: "https://example.com:8443/path", want: []Key{"example.com:8443"}},
20+ {name: "host-like string lowercases and trims trailing dot", raw: "Docs.Example.COM.", want: []Key{"docs.example.com", "example.com"}},
21+ {name: "strips www before preserving non default port", raw: "https://www.example.com:8443", want: []Key{"example.com:8443"}},
22+ {name: "host-like string with port preserves port exact", raw: "example.com:443", want: []Key{"example.com:443"}},
23+ {name: "unicode host normalizes to punycode", raw: "https://bücher.example/path", want: []Key{"xn--bcher-kva.example"}},
24+ {name: "punycode equivalent matches unicode", raw: "https://xn--bcher-kva.example/path", want: []Key{"xn--bcher-kva.example"}},
25+ {name: "empty input has no candidates", raw: "", want: nil},
26+ {name: "about url has no candidates", raw: "about:blank", want: nil},
27+ {name: "unsupported scheme has no candidates", raw: "file:///tmp/icon.html", want: nil},
28+ }
29+
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ got := Candidates(tt.raw)
33+ if len(got) != len(tt.want) {
34+ t.Fatalf("Candidates(%q) len = %d (%v), want %d (%v)", tt.raw, len(got), got, len(tt.want), tt.want)
35+ }
36+ for i := range got {
37+ if got[i] != tt.want[i] {
38+ t.Fatalf("Candidates(%q)[%d] = %q, want %q (all got %v)", tt.raw, i, got[i], tt.want[i], got)
39+ }
40+ }
41+ })
42+ }
43+}
44+
45+func TestCanonicalKey(t *testing.T) {
46+ key, ok := CanonicalKey("https://www.Example.COM.:443/path")
47+ if !ok {
48+ t.Fatal("CanonicalKey returned false")
49+ }
50+ if key != "example.com" {
51+ t.Fatalf("CanonicalKey = %q, want example.com", key)
52+ }
53+
54+ if key, ok := CanonicalKey("about:blank"); ok || key != "" {
55+ t.Fatalf("CanonicalKey(about:blank) = %q, %v; want empty false", key, ok)
56+ }
57+}
A internal/domain/favicon/metadata.go +24 -0
@@ -0,0 +1,24 @@
1+package favicon
2+
3+import "time"
4+
5+type Source string
6+
7+const (
8+ SourceEngine Source = "engine"
9+ SourcePageDiscovery Source = "page_discovery"
10+ SourceDuckDuckGo Source = "duckduckgo"
11+ SourceInternal Source = "internal"
12+ SourceManual Source = "manual"
13+)
14+
15+type Metadata struct {
16+ Key Key
17+ SourceURL string
18+ PageURL string
19+ Source Source
20+ ContentHash string
21+ ContentType string
22+ UpdatedAt time.Time
23+ LastCheckedAt time.Time
24+}
A internal/domain/favicon/policy.go +31 -0
@@ -0,0 +1,31 @@
1+package favicon
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "time"
7+)
8+
9+const DefaultTTL = 30 * 24 * time.Hour
10+
11+func Hash(data []byte) string {
12+ sum := sha256.Sum256(data)
13+ return hex.EncodeToString(sum[:])
14+}
15+
16+func ShouldRefresh(meta *Metadata, now time.Time, ttl time.Duration) bool {
17+ if meta == nil {
18+ return true
19+ }
20+ if ttl <= 0 {
21+ return true
22+ }
23+ return !meta.LastCheckedAt.Add(ttl).After(now)
24+}
25+
26+func HasContentChanged(meta *Metadata, data []byte) bool {
27+ if meta == nil {
28+ return true
29+ }
30+ return meta.ContentHash != Hash(data)
31+}
A internal/domain/favicon/policy_test.go +42 -0
@@ -0,0 +1,42 @@
1+package favicon
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestShouldRefresh(t *testing.T) {
9+ now := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
10+ ttl := 30 * 24 * time.Hour
11+
12+ if !ShouldRefresh(nil, now, ttl) {
13+ t.Fatal("missing metadata should refresh")
14+ }
15+
16+ fresh := &Metadata{LastCheckedAt: now.Add(-ttl + time.Minute)}
17+ if ShouldRefresh(fresh, now, ttl) {
18+ t.Fatal("fresh metadata should not refresh")
19+ }
20+
21+ stale := &Metadata{LastCheckedAt: now.Add(-ttl - time.Minute)}
22+ if !ShouldRefresh(stale, now, ttl) {
23+ t.Fatal("stale metadata should refresh")
24+ }
25+}
26+
27+func TestContentHashChanged(t *testing.T) {
28+ data := []byte("favicon bytes")
29+ meta := &Metadata{ContentHash: Hash(data)}
30+
31+ if HasContentChanged(meta, data) {
32+ t.Fatal("same bytes should not be changed")
33+ }
34+
35+ if !HasContentChanged(meta, []byte("different bytes")) {
36+ t.Fatal("different bytes should be changed")
37+ }
38+
39+ if !HasContentChanged(nil, data) {
40+ t.Fatal("missing metadata should be treated as changed")
41+ }
42+}
A internal/infrastructure/cef/favicon_discovery.go +141 -0
@@ -0,0 +1,141 @@
1+package cef
2+
3+import (
4+ "net/url"
5+ "strings"
6+
7+ "golang.org/x/net/html"
8+)
9+
10+// DiscoverFaviconCandidates extracts ordered favicon URL candidates from page
11+// metadata and appends the origin /favicon.ico fallback. Only http(s) page and
12+// icon URLs are returned; duplicates are removed while preserving order.
13+func DiscoverFaviconCandidates(pageURL, htmlSource string) []string {
14+ candidates := make([]string, 0, 4)
15+ for _, href := range parseFaviconHrefs(htmlSource) {
16+ if resolved, ok := resolveFaviconURL(pageURL, href); ok {
17+ candidates = append(candidates, resolved)
18+ }
19+ }
20+ if fallback, ok := originFaviconURL(pageURL); ok {
21+ candidates = append(candidates, fallback)
22+ }
23+ if len(candidates) == 0 {
24+ return nil
25+ }
26+ return dedupeStrings(candidates)
27+}
28+
29+func resolveFaviconCandidates(pageURL string, rawIconURLs []string) []string {
30+ candidates := make([]string, 0, len(rawIconURLs))
31+ for _, raw := range rawIconURLs {
32+ if resolved, ok := resolveFaviconURL(pageURL, raw); ok {
33+ candidates = append(candidates, resolved)
34+ }
35+ }
36+ return dedupeStrings(candidates)
37+}
38+
39+func fallbackFaviconCandidates(pageURL string) []string {
40+ fallback, ok := originFaviconURL(pageURL)
41+ if !ok {
42+ return nil
43+ }
44+ return []string{fallback}
45+}
46+
47+func parseFaviconHrefs(htmlSource string) []string {
48+ z := html.NewTokenizer(strings.NewReader(htmlSource))
49+ var hrefs []string
50+ for {
51+ switch z.Next() {
52+ case html.ErrorToken:
53+ return hrefs
54+ case html.StartTagToken, html.SelfClosingTagToken:
55+ tn, hasAttr := z.TagName()
56+ if !hasAttr || !strings.EqualFold(string(tn), "link") {
57+ continue
58+ }
59+ var rel, href string
60+ for {
61+ key, val, more := z.TagAttr()
62+ switch strings.ToLower(string(key)) {
63+ case "rel":
64+ rel = string(val)
65+ case "href":
66+ href = string(val)
67+ }
68+ if !more {
69+ break
70+ }
71+ }
72+ if href != "" && faviconRel(rel) {
73+ hrefs = append(hrefs, href)
74+ }
75+ }
76+ }
77+}
78+
79+func faviconRel(rel string) bool {
80+ fields := strings.Fields(strings.ToLower(rel))
81+ for _, field := range fields {
82+ if field == "icon" || field == "apple-touch-icon" || field == "apple-touch-icon-precomposed" {
83+ return true
84+ }
85+ }
86+ return false
87+}
88+
89+func resolveFaviconURL(pageURL, rawIconURL string) (string, bool) {
90+ icon, err := url.Parse(strings.TrimSpace(rawIconURL))
91+ if err != nil || rawIconURL == "" {
92+ return "", false
93+ }
94+ if icon.IsAbs() {
95+ if !isHTTPScheme(icon.Scheme) || icon.Host == "" {
96+ return "", false
97+ }
98+ return icon.String(), true
99+ }
100+ base, err := url.Parse(pageURL)
101+ if err != nil || !isHTTPScheme(base.Scheme) || base.Host == "" {
102+ return "", false
103+ }
104+ resolved := base.ResolveReference(icon)
105+ if !isHTTPScheme(resolved.Scheme) || resolved.Host == "" {
106+ return "", false
107+ }
108+ return resolved.String(), true
109+}
110+
111+func originFaviconURL(pageURL string) (string, bool) {
112+ u, err := url.Parse(pageURL)
113+ if err != nil || !isHTTPScheme(u.Scheme) || u.Host == "" {
114+ return "", false
115+ }
116+ u.Path = "/favicon.ico"
117+ u.RawPath = ""
118+ u.RawQuery = ""
119+ u.Fragment = ""
120+ return u.String(), true
121+}
122+
123+func isHTTPScheme(scheme string) bool {
124+ return scheme == "http" || scheme == "https"
125+}
126+
127+func dedupeStrings(values []string) []string {
128+ seen := make(map[string]struct{}, len(values))
129+ out := make([]string, 0, len(values))
130+ for _, value := range values {
131+ if value == "" {
132+ continue
133+ }
134+ if _, ok := seen[value]; ok {
135+ continue
136+ }
137+ seen[value] = struct{}{}
138+ out = append(out, value)
139+ }
140+ return out
141+}
A internal/infrastructure/cef/favicon_discovery_test.go +103 -0
@@ -0,0 +1,103 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ purecef "github.com/bnema/purego-cef/cef"
9+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestFaviconDiscoveryResolvesLinksAndFallback(t *testing.T) {
15+ pageURL := "https://example.com/docs/page.html"
16+ html := `<html><head>
17+ <link rel="stylesheet" href="/style.css">
18+ <link rel="icon" href="/favicon.png">
19+ <link rel="shortcut icon" href="icons/shortcut.ico">
20+ <link rel="apple-touch-icon" href="https://cdn.example.com/apple.png">
21+ <link rel="icon" href="javascript:alert(1)">
22+ </head></html>`
23+
24+ got := DiscoverFaviconCandidates(pageURL, html)
25+ require.Equal(t, []string{
26+ "https://example.com/favicon.png",
27+ "https://example.com/docs/icons/shortcut.ico",
28+ "https://cdn.example.com/apple.png",
29+ "https://example.com/favicon.ico",
30+ }, got)
31+}
32+
33+func TestFaviconDiscoveryFallbackOnly(t *testing.T) {
34+ require.Equal(t, []string{"https://example.com/favicon.ico"}, DiscoverFaviconCandidates("https://example.com/path", "<html></html>"))
35+ require.Nil(t, DiscoverFaviconCandidates("about:blank", "<html></html>"))
36+}
37+
38+func TestFaviconDiscoveryKeepsAbsoluteIconsForOpaquePageURL(t *testing.T) {
39+ got := DiscoverFaviconCandidates("about:blank", `<link rel="icon" href="https://cdn.example.com/icon.png">`)
40+ require.Equal(t, []string{"https://cdn.example.com/icon.png"}, got)
41+}
42+
43+func TestFaviconDiscoveryIgnoresUnsafeIconURLs(t *testing.T) {
44+ got := DiscoverFaviconCandidates("https://example.com/path", `<link rel="icon" href="data:image/png;base64,AAAA"><link rel="icon" href="//cdn.example.com/icon.png">`)
45+ require.Equal(t, []string{"https://cdn.example.com/icon.png", "https://example.com/favicon.ico"}, got)
46+}
47+
48+func TestCEFOnFaviconUrlchangeForwardsOrderedCandidates(t *testing.T) {
49+ prevDecode := decodeCEFStringList
50+ decodeCEFStringList = func(list purecef.StringList) []string {
51+ require.Equal(t, purecef.StringList(42), list)
52+ return []string{"/favicon.png", "https://cdn.example.com/icon.ico", "javascript:alert(1)"}
53+ }
54+ defer func() { decodeCEFStringList = prevDecode }()
55+
56+ var page string
57+ var icons []string
58+ wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{
59+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
60+ }}
61+
62+ (&handlerSet{wv: wv}).OnFaviconUrlchange(nil, purecef.StringList(42))
63+
64+ require.Equal(t, "https://example.com/docs/page", page)
65+ require.Equal(t, []string{"https://example.com/favicon.png", "https://cdn.example.com/icon.ico"}, icons)
66+}
67+
68+func TestCEFLoadingFinishedDiscoversMetadataBeforeFallback(t *testing.T) {
69+ prevNewStringVisitor := cefNewStringVisitor
70+ cefNewStringVisitor = func(visitor purecef.StringVisitor) purecef.StringVisitor { return visitor }
71+ defer func() { cefNewStringVisitor = prevNewStringVisitor }()
72+
73+ browser := cefmocks.NewMockBrowser(t)
74+ frame := cefmocks.NewMockFrame(t)
75+ browser.EXPECT().GetMainFrame().Return(frame)
76+ frame.EXPECT().GetSource(mock.Anything).Run(func(visitor purecef.StringVisitor) {
77+ visitor.Visit(`<html><head><link rel="icon" href="/meta.png"></head></html>`)
78+ }).Return()
79+
80+ var page string
81+ var icons []string
82+ wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{
83+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
84+ }}
85+
86+ (&handlerSet{wv: wv}).OnLoadingStateChange(browser, 0, 1, 0)
87+
88+ require.Equal(t, "https://example.com/docs/page", page)
89+ require.Equal(t, []string{"https://example.com/meta.png", "https://example.com/favicon.ico"}, icons)
90+}
91+
92+func TestCEFLoadingFinishedForwardsFallbackCandidate(t *testing.T) {
93+ var page string
94+ var icons []string
95+ wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{
96+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
97+ }}
98+
99+ (&handlerSet{wv: wv}).OnLoadingStateChange(nil, 0, 1, 0)
100+
101+ require.Equal(t, "https://example.com/docs/page", page)
102+ require.Equal(t, []string{"https://example.com/favicon.ico"}, icons)
103+}
M internal/infrastructure/cef/favicon_systemviews.go +25 -7
@@ -1,15 +1,33 @@
1 package cef1 package cef
2 2
3-import "github.com/bnema/dumber/internal/application/port"3+import (
4+ "context"
4 5
5-var _ port.SystemviewFaviconServiceSetter = (*Engine)(nil)6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/domain/favicon"
8+)
6 9
7-// SetSystemviewFaviconService exposes the local favicon cache to internal10+var _ port.SystemviewFaviconResolverSetter = (*Engine)(nil)
8-// systemview pages. The scheme handler only serves cached PNGs; it does not11+var _ port.FaviconInvalidator = (*Engine)(nil)
9-// fetch remote favicons on behalf of History rendering.12+
10-func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) {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) {
11 if e == nil || e.schemeHandler == nil {16 if e == nil || e.schemeHandler == nil {
12 return17 return
13 }18 }
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
15 }33 }
M internal/infrastructure/cef/scheme_handler.go +29 -113
@@ -12,7 +12,6 @@ import (
12 "io/fs"12 "io/fs"
13 "net/http"13 "net/http"
14 "net/url"14 "net/url"
15- "os"
16 "path"15 "path"
17 "strconv"16 "strconv"
18 "strings"17 "strings"
@@ -23,7 +22,6 @@ import (
23 purecef "github.com/bnema/purego-cef/cef"22 purecef "github.com/bnema/purego-cef/cef"
24 23
25 "github.com/bnema/dumber/internal/application/port"24 "github.com/bnema/dumber/internal/application/port"
26- domainurl "github.com/bnema/dumber/internal/domain/url"
27 "github.com/bnema/dumber/internal/infrastructure/webutil"25 "github.com/bnema/dumber/internal/infrastructure/webutil"
28 "github.com/bnema/dumber/internal/logging"26 "github.com/bnema/dumber/internal/logging"
29 "github.com/rs/zerolog"27 "github.com/rs/zerolog"
@@ -56,7 +54,7 @@ var cefNewResourceHandler = purecef.NewResourceHandler
56 type dumbSchemeHandler struct {54 type dumbSchemeHandler struct {
57 ctx context.Context55 ctx context.Context
58 messageRouter *MessageRouter56 messageRouter *MessageRouter
59- faviconService port.FaviconService57+ faviconResolver port.FaviconSystemviewResolver
60 assets embed.FS58 assets embed.FS
61 assetsSet bool59 assetsSet bool
62 assetDir string60 assetDir string
@@ -121,10 +119,10 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
121 h.logger.Debug().Msg("assets filesystem configured")119 h.logger.Debug().Msg("assets filesystem configured")
122 }120 }
123 121
124-func (h *dumbSchemeHandler) setFaviconService(service port.FaviconService) {122+func (h *dumbSchemeHandler) setFaviconResolver(resolver port.FaviconSystemviewResolver) {
125 h.mu.Lock()123 h.mu.Lock()
126 defer h.mu.Unlock()124 defer h.mu.Unlock()
127- h.faviconService = service125+ h.faviconResolver = resolver
128 }126 }
129 127
130 // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread128 // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread
@@ -311,9 +309,9 @@ func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.Re
311 }309 }
312 310
313 h.mu.RLock()311 h.mu.RLock()
314- service := h.faviconService312+ resolver := h.faviconResolver
315 h.mu.RUnlock()313 h.mu.RUnlock()
316- if service == nil {314+ if resolver == nil {
317 return h.newPrivateAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "favicon unavailable"})315 return h.newPrivateAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "favicon unavailable"})
318 }316 }
319 317
@@ -321,7 +319,7 @@ func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.Re
321 if err != nil {319 if err != nil {
322 return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})320 return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})
323 }321 }
324- domain := domainurl.CanonicalDomain(parsed.Query().Get("domain"))322+ domain := strings.TrimSpace(parsed.Query().Get("domain"))
325 if domain == "" {323 if domain == "" {
326 return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing domain"})324 return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing domain"})
327 }325 }
@@ -334,7 +332,7 @@ func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.Re
334 size = parsedSize332 size = parsedSize
335 }333 }
336 334
337- return newFaviconResourceHandler(service, domain, size)335+ return newFaviconResourceHandler(h.ctx, resolver, domain, size)
338 }336 }
339 337
340 func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler {338 func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler {
@@ -833,91 +831,25 @@ func (h *dumbSchemeHandler) newPrivateAPIJSONResourceHandler(status int, v any)
833 return h.newPrivateAPIRawResourceHandler(status, "application/json", data)831 return h.newPrivateAPIRawResourceHandler(status, "application/json", data)
834 }832 }
835 833
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 {
837 return cefNewResourceHandler(&faviconResourceHandler{840 return cefNewResourceHandler(&faviconResourceHandler{
838- service: service,841+ ctx: ctx,
839- domain: domain,842+ resolver: resolver,
840- size: size,843+ domain: domain,
841- done: make(chan struct{}),844+ size: size,
842- headers: map[string]string{"Cache-Control": "no-store"},845+ done: make(chan struct{}),
846+ headers: map[string]string{"Cache-Control": "no-store"},
843 })847 })
844 }848 }
845 849
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-
919 type faviconResourceHandler struct {850 type faviconResourceHandler struct {
920- service port.FaviconService851+ ctx context.Context
852+ resolver port.FaviconSystemviewResolver
921 domain string853 domain string
922 size int854 size int
923 headers map[string]string855 headers map[string]string
@@ -934,35 +866,19 @@ func (rh *faviconResourceHandler) load() {
934 rh.statusCode = http.StatusNotFound866 rh.statusCode = http.StatusNotFound
935 rh.contentType = "application/json"867 rh.contentType = "application/json"
936 rh.data = []byte(`{"error":"favicon not cached"}`)868 rh.data = []byte(`{"error":"favicon not cached"}`)
937- if rh.service == nil {869+ if rh.resolver == 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) {
948 return870 return
949 }871 }
950- diskPath := rh.service.DiskPathPNGSized(rh.domain, rh.size)872+ resolved, err := rh.resolver.ResolveSystemviewIcon(rh.ctx, rh.domain, rh.size)
951- if diskPath == "" {873+ if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
952- return
953- }
954- data, err := os.ReadFile(diskPath)
955- if err != nil || len(data) == 0 {
956 return874 return
957 }875 }
958 rh.statusCode = http.StatusOK876 rh.statusCode = http.StatusOK
959- rh.contentType = "image/png"877+ rh.contentType = resolved.ContentType
960- rh.data = data878+ if rh.contentType == "" {
961- systemviewFaviconCache.put(cacheKey, faviconCacheEntry{879+ rh.contentType = "image/png"
962- statusCode: rh.statusCode,880+ }
963- contentType: rh.contentType,881+ rh.data = resolved.Bytes
964- data: data,
965- })
966 }882 }
967 883
968 func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {884 func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {
M internal/infrastructure/cef/scheme_handler_test.go +5 -18
@@ -10,6 +10,8 @@ import (
10 purecef "github.com/bnema/purego-cef/cef"10 purecef "github.com/bnema/purego-cef/cef"
11 cefmocks "github.com/bnema/purego-cef/cef/mocks"11 cefmocks "github.com/bnema/purego-cef/cef/mocks"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/dumber/internal/application/port"
13 )15 )
14 16
15 func expectAPIResponseHeaders(response *cefmocks.MockResponse) {17 func expectAPIResponseHeaders(response *cefmocks.MockResponse) {
@@ -28,26 +30,11 @@ func expectPrivateAPIResponseHeaders(response *cefmocks.MockResponse) {
28 response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once()30 response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once()
29 }31 }
30 32
31-type panicFaviconService struct{}33+type panicSystemviewFaviconResolver struct{}
32 34
33-func (panicFaviconService) GetCached(context.Context, string) ([]byte, bool) {35+func (panicSystemviewFaviconResolver) ResolveSystemviewIcon(context.Context, string, int) (*port.ResolvedFavicon, error) {
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) {
48 panic("unexpected favicon lookup")36 panic("unexpected favicon lookup")
49 }37 }
50-func (panicFaviconService) Close() {}
51 38
52 // noopConfigPayload returns a builder that always returns an empty JSON object.39 // noopConfigPayload returns a builder that always returns an empty JSON object.
53 func noopConfigPayload() func() ([]byte, error) {40 func noopConfigPayload() func() ([]byte, error) {
@@ -310,7 +297,7 @@ func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T)
310 defer func() { cefNewResourceHandler = oldNewResourceHandler }()297 defer func() { cefNewResourceHandler = oldNewResourceHandler }()
311 298
312 h := newTestDumbSchemeHandler(t)299 h := newTestDumbSchemeHandler(t)
313- h.setFaviconService(panicFaviconService{})300+ h.setFaviconResolver(panicSystemviewFaviconResolver{})
314 301
315 request := cefmocks.NewMockRequest(t)302 request := cefmocks.NewMockRequest(t)
316 request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()303 request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
M internal/infrastructure/cef/webview.go +10 -4
@@ -67,10 +67,11 @@ const (
67 )67 )
68 68
69 var (69 var (
70- cefNewTask = purecef.NewTask70+ cefNewTask = purecef.NewTask
71- cefPostTask = purecef.PostTask71+ cefNewStringVisitor = purecef.NewStringVisitor
72- cefPostDelayedTask = purecef.PostDelayedTask72+ cefPostTask = purecef.PostTask
73- cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) }73+ cefPostDelayedTask = purecef.PostDelayedTask
74+ cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) }
74 )75 )
75 76
76 // WebView implements port.WebView using a CEF off-screen browser rendered and77 // WebView implements port.WebView using a CEF off-screen browser rendered and
@@ -199,6 +200,11 @@ type WebView struct {
199 // Last known hover URI for middle-click → new tab.200 // Last known hover URI for middle-click → new tab.
200 lastHoverURI string201 lastHoverURI string
201 202
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+
202 // Load diagnostics state (mutex-protected).208 // Load diagnostics state (mutex-protected).
203 loadDiagSeq uint64209 loadDiagSeq uint64
204 loadDiagStartedAt time.Time210 loadDiagStartedAt time.Time
M internal/infrastructure/cef/webview_handlers.go +110 -2
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "strings"5 "strings"
6 "sync"6 "sync"
7+ "sync/atomic"
8+ "time"
7 9
8 purecef "github.com/bnema/purego-cef/cef"10 purecef "github.com/bnema/purego-cef/cef"
9 11
@@ -176,7 +178,25 @@ func (h *handlerSet) OnTitleChange(_ purecef.Browser, title string) {
176 h.wv.updateTitle(title)178 h.wv.updateTitle(title)
177 }179 }
178 180
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+}
180 200
181 // OnFullscreenModeChange toggles the fullscreen atomic and fires callbacks.201 // OnFullscreenModeChange toggles the fullscreen atomic and fires callbacks.
182 func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32) {202 func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32) {
@@ -285,8 +305,23 @@ func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect)
285 // LoadHandler (4 methods)305 // LoadHandler (4 methods)
286 // ===========================================================================306 // ===========================================================================
287 307
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+
288 // OnLoadingStateChange updates the loading/navigation state cache and fires callbacks.323 // 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) {
290 loading := isloading != 0325 loading := isloading != 0
291 h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0)326 h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0)
292 if h.wv != nil && h.wv.ctx != nil {327 if h.wv != nil && h.wv.ctx != nil {
@@ -320,7 +355,11 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac
320 355
321 h.wv.mu.RLock()356 h.wv.mu.RLock()
322 cb := h.wv.callbacks357 cb := h.wv.callbacks
358+ pageURL := h.wv.uri
323 h.wv.mu.RUnlock()359 h.wv.mu.RUnlock()
360+ if !loading && cb != nil && cb.OnFaviconURLChanged != nil {
361+ h.emitDiscoveredFaviconCandidates(browser, pageURL, cb)
362+ }
324 if cb != nil && cb.OnLoadChanged != nil {363 if cb != nil && cb.OnLoadChanged != nil {
325 if loading {364 if loading {
326 h.wv.runOnGTK(func() {365 h.wv.runOnGTK(func() {
@@ -334,6 +373,75 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac
334 }373 }
335 }374 }
336 375
376+var faviconSourceVisitorRetentionID atomic.Uint64
377+
378+func (wv *WebView) retainFaviconSourceVisitor(visitor purecef.StringVisitor) func() {
379+ if wv == nil || visitor == nil {
380+ return func() {}
381+ }
382+ id := faviconSourceVisitorRetentionID.Add(1)
383+ wv.faviconSourceVisitorsMu.Lock()
384+ wv.faviconSourceVisitors = append(wv.faviconSourceVisitors, faviconSourceVisitorRetention{id: id, visitor: visitor})
385+ wv.faviconSourceVisitorsMu.Unlock()
386+ return func() {
387+ wv.faviconSourceVisitorsMu.Lock()
388+ defer wv.faviconSourceVisitorsMu.Unlock()
389+ for i, candidate := range wv.faviconSourceVisitors {
390+ if candidate.id == id {
391+ wv.faviconSourceVisitors = append(wv.faviconSourceVisitors[:i], wv.faviconSourceVisitors[i+1:]...)
392+ return
393+ }
394+ }
395+ }
396+}
397+
398+func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pageURL string, cb *port.WebViewCallbacks) {
399+ if h == nil || h.wv == nil || cb == nil || cb.OnFaviconURLChanged == nil {
400+ return
401+ }
402+ fallback := fallbackFaviconCandidates(pageURL)
403+ emit := func(candidates []string) {
404+ if len(candidates) == 0 {
405+ return
406+ }
407+ h.wv.runOnGTK(func() {
408+ cb.OnFaviconURLChanged(pageURL, candidates)
409+ })
410+ }
411+ if browser == nil {
412+ emit(fallback)
413+ return
414+ }
415+ frame := browser.GetMainFrame()
416+ if frame == nil {
417+ emit(fallback)
418+ return
419+ }
420+ var releaseOnce sync.Once
421+ var release func()
422+ visitor := cefNewStringVisitor(faviconSourceVisitor{visit: func(source string) {
423+ releaseOnce.Do(func() {
424+ if release != nil {
425+ release()
426+ }
427+ })
428+ candidates := DiscoverFaviconCandidates(pageURL, source)
429+ if len(candidates) == 0 {
430+ candidates = fallback
431+ }
432+ emit(candidates)
433+ }})
434+ release = h.wv.retainFaviconSourceVisitor(visitor)
435+ time.AfterFunc(5*time.Second, func() {
436+ releaseOnce.Do(func() {
437+ if release != nil {
438+ release()
439+ }
440+ })
441+ })
442+ frame.GetSource(visitor)
443+}
444+
337 // OnLoadStart fires LoadCommitted for main frame navigations.445 // OnLoadStart fires LoadCommitted for main frame navigations.
338 // In CEF this callback runs after navigation commit, so it is the closest446 // In CEF this callback runs after navigation commit, so it is the closest
339 // equivalent to WebKit's LoadCommitted event.447 // 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 @@
2 package favicon2 package favicon
3 3
4 import (4 import (
5+ "bytes"
5 "context"6 "context"
7+ "errors"
6 "fmt"8 "fmt"
7 "io"9 "io"
10+ "net"
8 "net/http"11 "net/http"
12+ "net/netip"
9 "net/url"13 "net/url"
14+ "strings"
10 "time"15 "time"
11 16
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"
13 )19 )
14 20
15 const (21 const (
16- // DuckDuckGo favicon API URL template.
17 duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico"22 duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico"
18- // HTTP client timeout for favicon fetch.23+ fetchTimeout = 5 * time.Second
19- fetchTimeout = 5 * time.Second24+ maxFaviconBytes = 1 << 20
25+ maxFaviconRedirects = 3
26+ sniffLen = 512
20 )27 )
21 28
22-// Fetcher retrieves favicons from external APIs.29+type fetchContextKey string
30+
31+const allowedLocalOriginKey fetchContextKey = "allowed-local-origin"
32+
23 type Fetcher struct {33 type Fetcher struct {
24- client *http.Client34+ client *http.Client
35+ duckDuckGoURL string
36+ maxBytes int64
25 }37 }
26 38
27-// NewFetcher creates a new Fetcher with default HTTP client settings.
28 func NewFetcher() *Fetcher {39 func NewFetcher() *Fetcher {
29- return &Fetcher{40+ f := &Fetcher{duckDuckGoURL: duckduckgoFaviconURL, maxBytes: maxFaviconBytes}
30- client: &http.Client{41+ transport := http.DefaultTransport.(*http.Transport).Clone()
31- Timeout: fetchTimeout,42+ transport.DialContext = f.safeDialContext
32- },43+ f.client = &http.Client{Timeout: fetchTimeout, Transport: transport, CheckRedirect: f.checkRedirect}
33- }44+ return f
34 }45 }
35 46
36-// Fetch retrieves favicon bytes for a domain from DuckDuckGo's favicon API.47+func NewFetcherWithClient(client *http.Client, duckDuckGoURL string) *Fetcher {
37-// Returns nil bytes if the favicon cannot be fetched.48+ f := &Fetcher{client: client, duckDuckGoURL: duckDuckGoURL, maxBytes: maxFaviconBytes}
38-func (f *Fetcher) Fetch(ctx context.Context, domain string) ([]byte, error) {49+ if f.client == nil {
39- if domain == "" {50+ f.client = &http.Client{Timeout: fetchTimeout}
40- return nil, nil
41 }51 }
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+}
42 59
43- log := logging.FromContext(ctx)60+func (f *Fetcher) safeTransport(base http.RoundTripper) http.RoundTripper {
44- faviconURL := fmt.Sprintf(duckduckgoFaviconURL, url.QueryEscape(domain))61+ transport, ok := base.(*http.Transport)
45-62+ if !ok || transport == nil {
46- log.Debug().Str("url", faviconURL).Msg("fetching favicon from DuckDuckGo")63+ transport = http.DefaultTransport.(*http.Transport)
64+ }
65+ clone := transport.Clone()
66+ clone.DialContext = f.safeDialContext
67+ return clone
68+}
47 69
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)
49 if err != nil {72 if err != nil {
50- log.Debug().Err(err).Msg("failed to create favicon request")
51 return nil, err73 return nil, err
52 }74 }
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+}
53 81
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+ }
55 if err != nil {91 if err != nil {
56- log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo")
57 return nil, err92 return nil, err
58 }93 }
59- defer func() {94+ return got.Bytes, nil
60- if closeErr := resp.Body.Close(); closeErr != nil {95+}
61- log.Debug().Err(closeErr).Str("domain", domain).Msg("failed to close favicon response body")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
62 }105 }
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+}
64 114
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() }()
65 if resp.StatusCode != http.StatusOK {126 if resp.StatusCode != http.StatusOK {
66- log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status")127+ return nil, "", appport.ErrFaviconMiss
67- return nil, nil128+ }
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
68 }159 }
160+ if !allowedURLForOrigin(req.Context(), req.URL) {
161+ return http.ErrUseLastResponse
162+ }
163+ return nil
164+}
69 165
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)
71 if err != nil {173 if err != nil {
72- log.Debug().Err(err).Str("domain", domain).Msg("failed to read favicon response")
73 return nil, err174 return nil, err
74 }175 }
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+}
75 191
76- if len(data) == 0 {192+func supportedFetchedContentType(ct string) bool {
77- log.Debug().Str("domain", domain).Msg("empty favicon response from DuckDuckGo")193+ switch ct {
78- return nil, nil194+ 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
79 }234 }
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+}
80 288
81- log.Debug().Str("domain", domain).Int("bytes", len(data)).Msg("favicon fetched from DuckDuckGo")289+func isPrivateAddr(addr netip.Addr) bool {
82- return data, nil290+ return addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsUnspecified()
83 }291 }
A internal/infrastructure/favicon/fetcher_test.go +119 -0
@@ -0,0 +1,119 @@
1+package favicon
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "net/url"
10+ "testing"
11+
12+ appport "github.com/bnema/dumber/internal/application/port"
13+)
14+
15+func TestFetcherExplicitIconURLAndDuckDuckGoFallback(t *testing.T) {
16+ png := testPNG(t, 8, 8)
17+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
18+ w.Header().Set("Content-Type", "image/png")
19+ _, _ = w.Write(png)
20+ }))
21+ defer srv.Close()
22+ fetcher := NewFetcherWithClient(srv.Client(), srv.URL+"/%s.ico")
23+
24+ got, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: srv.URL + "/page", IconURL: srv.URL + "/icon.png"})
25+ if err != nil || got.ContentType != "image/png" || len(got.Bytes) == 0 {
26+ t.Fatalf("explicit got=%+v err=%v", got, err)
27+ }
28+ got, err = fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com/page"})
29+ if err != nil || got.Source == "" || len(got.Bytes) == 0 {
30+ t.Fatalf("fallback got=%+v err=%v", got, err)
31+ }
32+}
33+
34+func TestFetcherMissesAndGuards(t *testing.T) {
35+ fetcher := NewFetcher()
36+ if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com", IconURL: "file:///x"}); !errors.Is(err, appport.ErrFaviconMiss) {
37+ t.Fatalf("scheme err=%v", err)
38+ }
39+ if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com", IconURL: "http://127.0.0.1/icon.png"}); !errors.Is(err, appport.ErrFaviconMiss) {
40+ t.Fatalf("ssrf err=%v", err)
41+ }
42+
43+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusNotFound) }))
44+ defer srv.Close()
45+ fetcher = NewFetcherWithClient(srv.Client(), "")
46+ if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: srv.URL, IconURL: srv.URL + "/missing"}); !errors.Is(err, appport.ErrFaviconMiss) {
47+ t.Fatalf("404 err=%v", err)
48+ }
49+}
50+
51+func TestFetcherRejectsMismatchedDeclaredContentType(t *testing.T) {
52+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
53+ w.Header().Set("Content-Type", "image/png")
54+ _, _ = w.Write([]byte("<html>not a png</html>"))
55+ }))
56+ defer srv.Close()
57+ fetcher := NewFetcherWithClient(srv.Client(), "")
58+ if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: srv.URL, IconURL: srv.URL + "/icon.png"}); !errors.Is(err, appport.ErrFaviconMiss) {
59+ t.Fatalf("mismatched content err=%v", err)
60+ }
61+}
62+
63+func TestFetcherLocalhostExceptionRequiresSameOrigin(t *testing.T) {
64+ page := mustParseURL(t, "http://localhost:3000/page")
65+ same := mustParseURL(t, "http://localhost:3000/favicon.ico")
66+ otherPort := mustParseURL(t, "http://localhost:8080/favicon.ico")
67+ otherScheme := mustParseURL(t, "https://localhost:3000/favicon.ico")
68+ defaultPortPage := mustParseURL(t, "http://localhost/page")
69+ defaultPortIcon := mustParseURL(t, "http://localhost:80/favicon.ico")
70+ if !allowedURLForPage(page.String(), same) {
71+ t.Fatal("same-origin localhost should be allowed")
72+ }
73+ if allowedURLForPage(page.String(), otherPort) {
74+ t.Fatal("different localhost port should be rejected")
75+ }
76+ if allowedURLForPage(page.String(), otherScheme) {
77+ t.Fatal("different localhost scheme should be rejected")
78+ }
79+ if !allowedURLForPage(defaultPortPage.String(), defaultPortIcon) {
80+ t.Fatal("default localhost port should normalize as same-origin")
81+ }
82+ if got := allowedLocalOrigin(defaultPortPage.String()); got != "localhost:80" {
83+ t.Fatalf("allowedLocalOrigin default port = %q, want localhost:80", got)
84+ }
85+ if !allowedURLForOrigin(context.WithValue(context.Background(), allowedLocalOriginKey, allowedLocalOrigin(defaultPortPage.String())), mustParseURL(t, "http://localhost/favicon.ico")) {
86+ t.Fatal("same-origin localhost redirect with implicit default port should be allowed")
87+ }
88+}
89+
90+func mustParseURL(t *testing.T, raw string) *url.URL {
91+ t.Helper()
92+ u, err := url.Parse(raw)
93+ if err != nil {
94+ t.Fatal(err)
95+ }
96+ return u
97+}
98+
99+func TestFetcherMaxSizeAndRedirectLimit(t *testing.T) {
100+ big := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
101+ w.Header().Set("Content-Type", "image/png")
102+ _, _ = w.Write(make([]byte, 20))
103+ }))
104+ defer big.Close()
105+ fetcher := NewFetcherWithClient(big.Client(), "")
106+ fetcher.maxBytes = 4
107+ if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: big.URL, IconURL: big.URL}); err == nil {
108+ t.Fatal("expected max size error")
109+ }
110+
111+ redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112+ http.Redirect(w, r, fmt.Sprintf("/r%s", r.URL.Path), http.StatusFound)
113+ }))
114+ defer redir.Close()
115+ fetcher = NewFetcherWithClient(redir.Client(), "")
116+ if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: redir.URL, IconURL: redir.URL + "/r"}); !errors.Is(err, appport.ErrFaviconMiss) {
117+ t.Fatalf("redirect err=%v", err)
118+ }
119+}
A internal/infrastructure/favicon/scheduler.go +40 -0
@@ -0,0 +1,40 @@
1+package favicon
2+
3+import (
4+ "context"
5+ "sync"
6+
7+ "github.com/bnema/dumber/internal/domain/favicon"
8+)
9+
10+type RefreshScheduler struct {
11+ mu sync.Mutex
12+ running map[favicon.Key]struct{}
13+}
14+
15+func NewRefreshScheduler() *RefreshScheduler {
16+ return &RefreshScheduler{running: make(map[favicon.Key]struct{})}
17+}
18+
19+func (s *RefreshScheduler) Schedule(ctx context.Context, key favicon.Key, work func(context.Context)) bool {
20+ if s == nil || key == "" || work == nil {
21+ return false
22+ }
23+ s.mu.Lock()
24+ if _, ok := s.running[key]; ok {
25+ s.mu.Unlock()
26+ return false
27+ }
28+ s.running[key] = struct{}{}
29+ s.mu.Unlock()
30+
31+ go func() {
32+ defer func() {
33+ s.mu.Lock()
34+ delete(s.running, key)
35+ s.mu.Unlock()
36+ }()
37+ work(ctx)
38+ }()
39+ return true
40+}
M internal/infrastructure/favicon/service.go +20 -69
@@ -15,24 +15,27 @@ const InternalScheme = "dumb://"
15 // InternalDomain is the pseudo-domain used for caching internal page favicons.15 // InternalDomain is the pseudo-domain used for caching internal page favicons.
16 const InternalDomain = "dumb"16 const InternalDomain = "dumb"
17 17
18-// Service implements the domain FaviconService interface.18+// Service is the legacy favicon cache adapter kept for callers that have not yet
19-// It coordinates between the cache and fetcher components.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.
20 type Service struct {21 type Service struct {
21- cache *Cache22+ cache *Cache
22- fetcher *Fetcher
23 }23 }
24 24
25 // NewService creates a new favicon service.25 // NewService creates a new favicon service.
26 // cacheDir is the directory for disk caching; empty string disables disk caching.26 // cacheDir is the directory for disk caching; empty string disables disk caching.
27 func NewService(cacheDir string) *Service {27 func NewService(cacheDir string) *Service {
28- return &Service{28+ return &Service{cache: NewCache(cacheDir)}
29- cache: NewCache(cacheDir),29+}
30- fetcher: NewFetcher(),30+
31+func (s *Service) Cache() *Cache {
32+ if s == nil {
33+ return nil
31 }34 }
35+ return s.cache
32 }36 }
33 37
34-// Get returns favicon bytes for a domain.38+// Get returns favicon bytes for a domain from the legacy cache only.
35-// Checks memory cache, then disk cache, then fetches from external API.
36 func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {39 func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
37 if domain == "" {40 if domain == "" {
38 return nil, nil41 return nil, nil
@@ -46,7 +49,6 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
46 return assets.LogoSVG, nil49 return assets.LogoSVG, nil
47 }50 }
48 51
49- // Check cache first (memory + disk)
50 if data, ok := s.cache.Get(ctx, domain); ok {52 if data, ok := s.cache.Get(ctx, domain); ok {
51 log.Debug().53 log.Debug().
52 Str("domain", domain).54 Str("domain", domain).
@@ -54,24 +56,8 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
54 Msg("favicon: Service.Get cache hit")56 Msg("favicon: Service.Get cache hit")
55 return data, nil57 return data, nil
56 }58 }
57-59+ log.Debug().Str("domain", domain).Msg("favicon: Service.Get cache miss")
58- // Fetch from external API60+ return nil, nil
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
75 }61 }
76 62
77 // IsInternalURL checks if a URL uses the internal dumb:// scheme.63 // IsInternalURL checks if a URL uses the internal dumb:// scheme.
@@ -127,35 +113,10 @@ func (s *Service) Close() {
127 s.cache.Close()113 s.cache.Close()
128 }114 }
129 115
130-// EnsureDiskCache fetches favicon from external API and saves to disk if not already cached.116+// EnsureDiskCache is a legacy no-op. Disk population is handled by explicit
131-// This is used when we receive a texture from WebKit but want to ensure disk persistence.117+// texture/blob writes after EnsureCacheDir; remote refresh policy belongs to the application usecase.
132-func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {118+func (*Service) EnsureDiskCache(ctx context.Context, domain string) {
133- if domain == "" {119+ logging.FromContext(ctx).Debug().Str("domain", domain).Msg("favicon: legacy EnsureDiskCache skipped")
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")
159 }120 }
160 121
161 // DiskPathPNGSized returns the filesystem path for a sized PNG favicon.122 // DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
@@ -168,8 +129,8 @@ func (s *Service) HasPNGSizedOnDisk(domain string, size int) bool {
168 return s.cache.HasPNGSizedOnDisk(domain, size)129 return s.cache.HasPNGSizedOnDisk(domain, size)
169 }130 }
170 131
171-// EnsureSizedPNG creates a resized PNG from the original if it doesn't exist.132+// EnsureSizedPNG creates or overwrites a resized PNG from the original PNG.
172-// This is used to generate normalized icons for dmenu/fuzzel.133+// This is a legacy adapter method; new code should use the favicon usecase.
173 func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) error {134 func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) error {
174 if domain == "" {135 if domain == "" {
175 return nil136 return nil
@@ -180,16 +141,6 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e
180 Int("size", size).141 Int("size", size).
181 Msg("favicon: EnsureSizedPNG begin")142 Msg("favicon: EnsureSizedPNG begin")
182 143
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
193 srcPath := s.DiskPathPNG(domain)144 srcPath := s.DiskPathPNG(domain)
194 if srcPath == "" || !s.HasPNGOnDisk(domain) {145 if srcPath == "" || !s.HasPNGOnDisk(domain) {
195 return fmt.Errorf("source PNG not found for domain %s", domain)146 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 {
15 CreatedAt sql.NullTime `json:"created_at"`15 CreatedAt sql.NullTime `json:"created_at"`
16 }16 }
17 17
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+
18 type Favorite struct {29 type Favorite struct {
19 ID int64 `json:"id"`30 ID int64 `json:"id"`
20 Url string `json:"url"`31 Url string `json:"url"`
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +4 -0
@@ -19,6 +19,7 @@ type Querier interface {
19 DeleteAllHistory(ctx context.Context) error19 DeleteAllHistory(ctx context.Context) error
20 // Deletes exited browser sessions older than the given cutoff time.20 // Deletes exited browser sessions older than the given cutoff time.
21 DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error)21 DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error)
22+ DeleteFavicon(ctx context.Context, key string) error
22 DeleteFavorite(ctx context.Context, id int64) error23 DeleteFavorite(ctx context.Context, id int64) error
23 DeleteFolder(ctx context.Context, id int64) error24 DeleteFolder(ctx context.Context, id int64) error
24 DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error25 DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error
@@ -44,6 +45,7 @@ type Querier interface {
44 GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)45 GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error)
45 GetDailyVisitCount(ctx context.Context, date interface{}) ([]GetDailyVisitCountRow, error)46 GetDailyVisitCount(ctx context.Context, date interface{}) ([]GetDailyVisitCountRow, error)
46 GetDomainStats(ctx context.Context, limit int64) ([]GetDomainStatsRow, error)47 GetDomainStats(ctx context.Context, limit int64) ([]GetDomainStatsRow, error)
48+ GetFavicon(ctx context.Context, key string) (Favicon, error)
47 GetFavoriteByID(ctx context.Context, id int64) (Favorite, error)49 GetFavoriteByID(ctx context.Context, id int64) (Favorite, error)
48 GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error)50 GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error)
49 GetFavoriteByURL(ctx context.Context, url string) (Favorite, error)51 GetFavoriteByURL(ctx context.Context, url string) (Favorite, error)
@@ -90,11 +92,13 @@ type Querier interface {
90 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error92 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
91 SetPermission(ctx context.Context, arg SetPermissionParams) error93 SetPermission(ctx context.Context, arg SetPermissionParams) error
92 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error94 SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error
95+ UpdateFaviconLastChecked(ctx context.Context, arg UpdateFaviconLastCheckedParams) error
93 UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error96 UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error
94 UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error97 UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error
95 UpdateFolder(ctx context.Context, arg UpdateFolderParams) error98 UpdateFolder(ctx context.Context, arg UpdateFolderParams) error
96 UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error99 UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error
97 UpdateTag(ctx context.Context, arg UpdateTagParams) error100 UpdateTag(ctx context.Context, arg UpdateTagParams) error
101+ UpsertFavicon(ctx context.Context, arg UpsertFaviconParams) error
98 UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error102 UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error
99 UpsertSessionState(ctx context.Context, arg UpsertSessionStateParams) error103 UpsertSessionState(ctx context.Context, arg UpsertSessionStateParams) error
100 }104 }
M internal/infrastructure/webkit/favicon_systemviews.go +5 -5
@@ -2,13 +2,13 @@ package webkit
2 2
3 import "github.com/bnema/dumber/internal/application/port"3 import "github.com/bnema/dumber/internal/application/port"
4 4
5-var _ port.SystemviewFaviconServiceSetter = (*Engine)(nil)5+var _ port.SystemviewFaviconResolverSetter = (*Engine)(nil)
6 6
7-// SetSystemviewFaviconService exposes cached favicons to systemview pages via7+// SetSystemviewFaviconResolver exposes the application favicon resolver to
8-// dumb://api/favicon without triggering remote favicon fetches during History rendering.8+// systemview pages via dumb://api/favicon without disk-service access.
9-func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) {9+func (e *Engine) SetSystemviewFaviconResolver(resolver port.FaviconSystemviewResolver) {
10 if e == nil || e.schemeHandler == nil {10 if e == nil || e.schemeHandler == nil {
11 return11 return
12 }12 }
13- e.schemeHandler.SetFaviconService(service)13+ e.schemeHandler.SetFaviconResolver(resolver)
14 }14 }
M internal/infrastructure/webkit/scheme_handler.go +13 -18
@@ -9,7 +9,6 @@ import (
9 "io/fs"9 "io/fs"
10 "net/http"10 "net/http"
11 "net/url"11 "net/url"
12- "os"
13 "path"12 "path"
14 "strconv"13 "strconv"
15 "strings"14 "strings"
@@ -17,7 +16,6 @@ import (
17 16
18 "github.com/andybalholm/brotli"17 "github.com/andybalholm/brotli"
19 "github.com/bnema/dumber/internal/application/port"18 "github.com/bnema/dumber/internal/application/port"
20- domainurl "github.com/bnema/dumber/internal/domain/url"
21 "github.com/bnema/dumber/internal/infrastructure/webutil"19 "github.com/bnema/dumber/internal/infrastructure/webutil"
22 "github.com/bnema/dumber/internal/logging"20 "github.com/bnema/dumber/internal/logging"
23 "github.com/bnema/puregotk/v4/gio"21 "github.com/bnema/puregotk/v4/gio"
@@ -75,7 +73,7 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
75 type DumbSchemeHandler struct {73 type DumbSchemeHandler struct {
76 handlers map[string]PageHandler74 handlers map[string]PageHandler
77 assets embed.FS75 assets embed.FS
78- faviconService port.FaviconService76+ faviconResolver port.FaviconSystemviewResolver
79 assetDir string // default subdirectory within embed.FS (e.g., "systemviews")77 assetDir string // default subdirectory within embed.FS (e.g., "systemviews")
80 logger zerolog.Logger78 logger zerolog.Logger
81 mu sync.RWMutex79 mu sync.RWMutex
@@ -116,10 +114,10 @@ func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload fun
116 h.logger.Debug().Msg("config payload builders configured")114 h.logger.Debug().Msg("config payload builders configured")
117 }115 }
118 116
119-func (h *DumbSchemeHandler) SetFaviconService(service port.FaviconService) {117+func (h *DumbSchemeHandler) SetFaviconResolver(resolver port.FaviconSystemviewResolver) {
120 h.mu.Lock()118 h.mu.Lock()
121 defer h.mu.Unlock()119 defer h.mu.Unlock()
122- h.faviconService = service120+ h.faviconResolver = resolver
123 }121 }
124 122
125 // registerDefaults sets up default page handlers.123 // registerDefaults sets up default page handlers.
@@ -210,9 +208,9 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
210 return privateJSONErrorResponse(http.StatusForbidden, "forbidden")208 return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
211 }209 }
212 h.mu.RLock()210 h.mu.RLock()
213- service := h.faviconService211+ resolver := h.faviconResolver
214 h.mu.RUnlock()212 h.mu.RUnlock()
215- if service == nil {213+ if resolver == nil {
216 return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable")214 return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable")
217 }215 }
218 if req == nil {216 if req == nil {
@@ -222,7 +220,7 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
222 if err != nil {220 if err != nil {
223 return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL")221 return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL")
224 }222 }
225- domain := domainurl.CanonicalDomain(parsed.Query().Get("domain"))223+ domain := strings.TrimSpace(parsed.Query().Get("domain"))
226 if domain == "" {224 if domain == "" {
227 return privateJSONErrorResponse(http.StatusBadRequest, "missing domain")225 return privateJSONErrorResponse(http.StatusBadRequest, "missing domain")
228 }226 }
@@ -235,20 +233,17 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
235 size = parsedSize233 size = parsedSize
236 }234 }
237 235
238- if !service.HasPNGSizedOnDisk(domain, size) {236+ resolved, err := resolver.ResolveSystemviewIcon(context.Background(), domain, size)
237+ if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
239 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")238 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
240 }239 }
241- diskPath := service.DiskPathPNGSized(domain, size)240+ contentType := resolved.ContentType
242- if diskPath == "" {241+ if contentType == "" {
243- return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")242+ contentType = "image/png"
244- }
245- data, err := os.ReadFile(diskPath)
246- if err != nil || len(data) == 0 {
247- return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
248 }243 }
249 return &SchemeResponse{244 return &SchemeResponse{
250- Data: data,245+ Data: resolved.Bytes,
251- ContentType: "image/png",246+ ContentType: contentType,
252 StatusCode: http.StatusOK,247 StatusCode: http.StatusOK,
253 Headers: map[string]string{"Cache-Control": "no-store"},248 Headers: map[string]string{"Cache-Control": "no-store"},
254 SuppressDefaultHeaders: true,249 SuppressDefaultHeaders: true,
M internal/ui/adapter/favicon.go +161 -28
@@ -8,6 +8,7 @@ import (
8 "time"8 "time"
9 9
10 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/favicon"
11 domainurl "github.com/bnema/dumber/internal/domain/url"12 domainurl "github.com/bnema/dumber/internal/domain/url"
12 "github.com/bnema/dumber/internal/logging"13 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/puregotk/v4/gdk"14 "github.com/bnema/puregotk/v4/gdk"
@@ -19,6 +20,7 @@ import (
19 // gdk.Texture conversion and WebKit FaviconDatabase integration.20 // gdk.Texture conversion and WebKit FaviconDatabase integration.
20 type FaviconAdapter struct {21 type FaviconAdapter struct {
21 service port.FaviconService22 service port.FaviconService
23+ resolver port.FaviconResolver
22 faviconDB port.FaviconDatabase24 faviconDB port.FaviconDatabase
23 textureCache map[string]*gdk.Texture25 textureCache map[string]*gdk.Texture
24 mu sync.RWMutex26 mu sync.RWMutex
@@ -57,9 +59,19 @@ type FaviconAdapterConfig struct {
57 // NewFaviconAdapter creates a new FaviconAdapter.59 // NewFaviconAdapter creates a new FaviconAdapter.
58 // The faviconDB can be nil if a favicon database is not available.60 // The faviconDB can be nil if a favicon database is not available.
59 func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {61 func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {
62+ return NewFaviconAdapterWithResolver(service, nil, faviconDB, cfg)
63+}
64+
65+func NewFaviconAdapterWithResolver(
66+ service port.FaviconService,
67+ resolver port.FaviconResolver,
68+ faviconDB port.FaviconDatabase,
69+ cfg FaviconAdapterConfig,
70+) *FaviconAdapter {
60 disabled := os.Getenv(disableFaviconsEnv) == "1"71 disabled := os.Getenv(disableFaviconsEnv) == "1"
61 return &FaviconAdapter{72 return &FaviconAdapter{
62 service: service,73 service: service,
74+ resolver: resolver,
63 faviconDB: faviconDB,75 faviconDB: faviconDB,
64 textureCache: make(map[string]*gdk.Texture),76 textureCache: make(map[string]*gdk.Texture),
65 warnCounts: make(map[string]int),77 warnCounts: make(map[string]int),
@@ -132,26 +144,34 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
132 return144 return
133 }145 }
134 146
147+ // Check application resolver before falling back to the legacy service.
148+ if texture := a.resolveTexture(ctx, pageURL, true); texture != nil {
149+ callback(texture)
150+ return
151+ }
152+
135 // Check service cache (memory + disk) and convert to texture153 // Check service cache (memory + disk) and convert to texture
136- if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {154+ if a.service != nil {
137- log.Debug().155+ if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
138- Str("domain", domain).156+ log.Debug().
139- Int("bytes", len(data)).157+ Str("domain", domain).
140- Msg("favicon: service cache hit")158+ Int("bytes", len(data)).
141- texture := a.textureFromBytesOnGTK(ctx, data)159+ Msg("favicon: service cache hit")
142- if texture != nil {160+ texture := a.textureFromBytesOnGTK(ctx, data)
143- a.setTexture(domain, texture)161+ if texture != nil {
144- // Ensure sized PNG exists for CLI tools (async, idempotent)162+ a.setTexture(domain, texture)
145- go a.ensureSizedPNG(ctx, domain)163+ // Ensure sized PNG exists for CLI tools (async, idempotent)
146- callback(texture)164+ go a.ensureSizedPNG(ctx, domain)
147- return165+ callback(texture)
166+ return
167+ }
148 }168 }
149 }169 }
150 170
151 // Try engine FaviconDatabase if available171 // Try engine FaviconDatabase if available
152 if a.faviconDB != nil {172 if a.faviconDB != nil {
153 a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {173 a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {
154- a.handleEngineFaviconDBTexture(ctx, domain, callback, tex)174+ a.handleEngineFaviconDBTexture(ctx, pageURL, domain, callback, tex)
155 })175 })
156 return176 return
157 }177 }
@@ -162,6 +182,9 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
162 }182 }
163 183
164 func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string, callback func(*gdk.Texture), texture *gdk.Texture) {184 func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string, callback func(*gdk.Texture), texture *gdk.Texture) {
185+ if callback == nil {
186+ return
187+ }
165 log := logging.FromContext(ctx)188 log := logging.FromContext(ctx)
166 log.Debug().189 log.Debug().
167 Str("domain", domain).190 Str("domain", domain).
@@ -178,7 +201,49 @@ func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string,
178 glib.IdleAdd(&cb, 0)201 glib.IdleAdd(&cb, 0)
179 }202 }
180 203
204+func (a *FaviconAdapter) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string, callback func(*gdk.Texture)) {
205+ domain := domainurl.ExtractDomain(pageURL)
206+ if a.resolver == nil {
207+ a.invokeCallbackOnGTK(ctx, domain, callback, nil)
208+ return
209+ }
210+ go func() {
211+ if err := a.resolver.RefreshFromIconURLs(ctx, pageURL, iconURLs); err != nil {
212+ logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: RefreshFromIconURLs failed")
213+ }
214+ if callback == nil {
215+ return
216+ }
217+ a.GetOrFetch(ctx, pageURL, func(texture *gdk.Texture) {
218+ a.invokeCallbackOnGTK(ctx, domain, callback, texture)
219+ })
220+ }()
221+}
222+
223+func (a *FaviconAdapter) resolveTexture(ctx context.Context, pageURL string, scheduleRefresh bool) *gdk.Texture {
224+ if a.resolver == nil {
225+ return nil
226+ }
227+ options := port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI}
228+ if scheduleRefresh {
229+ options.ScheduleBackgroundRefresh = true
230+ }
231+ resolved, err := a.resolver.Resolve(ctx, pageURL, 0, options)
232+ if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
233+ return nil
234+ }
235+ texture := a.textureFromBytesOnGTK(ctx, resolved.Bytes)
236+ if texture != nil {
237+ a.setTexture(string(resolved.Key), texture)
238+ }
239+ return texture
240+}
241+
181 func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) {242 func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) {
243+ if a.service == nil {
244+ a.invokeCallbackOnGTK(ctx, domain, callback, nil)
245+ return
246+ }
182 go func() {247 go func() {
183 log := logging.FromContext(ctx)248 log := logging.FromContext(ctx)
184 log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin")249 log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin")
@@ -209,7 +274,12 @@ func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, cal
209 }()274 }()
210 }275 }
211 276
212-func (a *FaviconAdapter) handleEngineFaviconDBTexture(ctx context.Context, domain string, callback func(*gdk.Texture), tex port.Texture) {277+func (a *FaviconAdapter) handleEngineFaviconDBTexture(
278+ ctx context.Context,
279+ pageURL, domain string,
280+ callback func(*gdk.Texture),
281+ tex port.Texture,
282+) {
213 log := logging.FromContext(ctx)283 log := logging.FromContext(ctx)
214 log.Debug().284 log.Debug().
215 Str("domain", domain).285 Str("domain", domain).
@@ -228,7 +298,7 @@ func (a *FaviconAdapter) handleEngineFaviconDBTexture(ctx context.Context, domai
228 }298 }
229 a.setTexture(domain, gdkTex)299 a.setTexture(domain, gdkTex)
230 a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex)300 a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex)
231- a.saveFaviconToDisk(ctx, domain, gdkTex)301+ a.storeTextureObservation(ctx, pageURL, gdkTex)
232 }302 }
233 303
234 // StoreFromWebKit stores a favicon texture received from WebKit signals.304 // StoreFromWebKit stores a favicon texture received from WebKit signals.
@@ -252,7 +322,12 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te
252 // Store in texture cache322 // Store in texture cache
253 a.setTexture(domain, texture)323 a.setTexture(domain, texture)
254 324
255- // Export as PNG and ensure disk cache325+ if a.resolver != nil {
326+ a.storeTextureObservation(ctx, pageURL, texture)
327+ return
328+ }
329+
330+ // Legacy fallback for callers not yet wired to the application resolver.
256 a.saveFaviconToDisk(ctx, domain, texture)331 a.saveFaviconToDisk(ctx, domain, texture)
257 }332 }
258 333
@@ -269,14 +344,55 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
269 currentDomain := domainurl.ExtractDomain(currentURL)344 currentDomain := domainurl.ExtractDomain(currentURL)
270 if originDomain != "" && originDomain != currentDomain {345 if originDomain != "" && originDomain != currentDomain {
271 a.setTexture(originDomain, texture)346 a.setTexture(originDomain, texture)
272- a.saveFaviconToDisk(ctx, originDomain, texture)347+ if a.resolver != nil {
348+ a.storeTextureObservation(ctx, originURL, texture)
349+ } else {
350+ a.saveFaviconToDisk(ctx, originDomain, texture)
351+ }
273 }352 }
274 }353 }
275 }354 }
276 355
356+func (a *FaviconAdapter) storeTextureObservation(ctx context.Context, pageURL string, texture *gdk.Texture) {
357+ if a.resolver == nil || texture == nil || pageURL == "" {
358+ return
359+ }
360+ cb := glib.SourceFunc(func(_ uintptr) bool {
361+ tmp, err := os.CreateTemp("", "dumber-favicon-*.png")
362+ if err != nil {
363+ logging.FromContext(ctx).Debug().Err(err).Msg("favicon: failed to create temp PNG for observation")
364+ return false
365+ }
366+ path := tmp.Name()
367+ _ = tmp.Close()
368+ defer func() { _ = os.Remove(path) }()
369+ if ok := texture.SaveToPng(path); !ok {
370+ logging.FromContext(ctx).Debug().Str("page_url", pageURL).Msg("favicon: failed to export texture for observation")
371+ return false
372+ }
373+ data, err := os.ReadFile(path)
374+ if err != nil || len(data) == 0 {
375+ logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: failed to read exported observation")
376+ return false
377+ }
378+ go func() {
379+ // Engine-provided textures do not expose the original icon URL, so pageURL
380+ // is passed as the observation iconURL fallback for SourceEngine records.
381+ if _, err := a.resolver.Observe(ctx, pageURL, pageURL, data, favicon.SourceEngine, "image/png"); err != nil {
382+ logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: failed to observe engine texture")
383+ }
384+ }()
385+ return false
386+ })
387+ glib.IdleAdd(&cb, 0)
388+}
389+
277 // saveFaviconToDisk exports a favicon texture to PNG and ensures disk cache is populated.390 // saveFaviconToDisk exports a favicon texture to PNG and ensures disk cache is populated.
278 // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.391 // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking.
279 func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {392 func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {
393+ if a.service == nil {
394+ return
395+ }
280 log := logging.FromContext(ctx)396 log := logging.FromContext(ctx)
281 cacheDirWarnKey := "cache-dir"397 cacheDirWarnKey := "cache-dir"
282 savePNGWarnKey := "save-png:" + domain398 savePNGWarnKey := "save-png:" + domain
@@ -359,16 +475,23 @@ func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *
359 return texture475 return texture
360 }476 }
361 477
478+ // Check application resolver before falling back to the legacy service.
479+ if texture := a.resolveTexture(ctx, pageURL, false); texture != nil {
480+ return texture
481+ }
482+
362 // Check service cache (memory + disk)483 // Check service cache (memory + disk)
363- if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {484+ if a.service != nil {
364- log.Debug().485+ if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
365- Str("domain", domain).486+ log.Debug().
366- Int("bytes", len(data)).487+ Str("domain", domain).
367- Msg("favicon: PreloadFromCache service hit")488+ Int("bytes", len(data)).
368- texture := a.textureFromBytesOnGTK(ctx, data)489+ Msg("favicon: PreloadFromCache service hit")
369- if texture != nil {490+ texture := a.textureFromBytesOnGTK(ctx, data)
370- a.setTexture(domain, texture)491+ if texture != nil {
371- return texture492+ a.setTexture(domain, texture)
493+ return texture
494+ }
372 }495 }
373 }496 }
374 497
@@ -384,7 +507,17 @@ func (a *FaviconAdapter) Service() port.FaviconService {
384 507
385 // Close shuts down the adapter and underlying service.508 // Close shuts down the adapter and underlying service.
386 func (a *FaviconAdapter) Close() {509 func (a *FaviconAdapter) Close() {
387- a.service.Close()510+ if a.service != nil {
511+ a.service.Close()
512+ }
513+}
514+
515+// Invalidate clears one resolved-key texture cache entry.
516+func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {
517+ a.mu.Lock()
518+ delete(a.textureCache, string(key))
519+ a.mu.Unlock()
520+ return nil
388 }521 }
389 522
390 // Clear removes all entries from the texture cache.523 // Clear removes all entries from the texture cache.
@@ -415,7 +548,7 @@ func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
415 // ensureSizedPNG ensures the sized PNG exists for CLI tools.548 // ensureSizedPNG ensures the sized PNG exists for CLI tools.
416 // This is idempotent - it only creates the file if it doesn't exist.549 // This is idempotent - it only creates the file if it doesn't exist.
417 func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {550 func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
418- if domain == "" {551+ if domain == "" || a.service == nil {
419 return552 return
420 }553 }
421 554
M internal/ui/app.go +36 -12
@@ -97,10 +97,10 @@ type App struct {
97 kbDispatcher *dispatcher.KeyboardDispatcher97 kbDispatcher *dispatcher.KeyboardDispatcher
98 98
99 // Pane management (used by coordinators)99 // Pane management (used by coordinators)
100- panesUC *usecase.ManagePanesUseCase100+ panesUC *usecase.ManagePanesUseCase
101- workspaceViews map[entity.TabID]*component.WorkspaceView101+ workspaceViews map[entity.TabID]*component.WorkspaceView
102- windowForTab map[entity.TabID]*browserWindow102+ windowForTab map[entity.TabID]*browserWindow
103- widgetFactory layout.WidgetFactory103+ widgetFactory layout.WidgetFactory
104 workspaceViewCreateOverride func(context.Context, *entity.Tab) bool104 workspaceViewCreateOverride func(context.Context, *entity.Tab) bool
105 stackedPaneMgr *component.StackedPaneManager105 stackedPaneMgr *component.StackedPaneManager
106 106
@@ -220,8 +220,14 @@ func New(deps *Dependencies) (*App, error) {
220 glib.IdleAdd(&cb, 0)220 glib.IdleAdd(&cb, 0)
221 })221 })
222 }222 }
223- if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconServiceSetter); ok {223+ if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconResolverSetter); ok {
224- faviconSetter.SetSystemviewFaviconService(deps.FaviconService)224+ resolver := deps.SystemviewFaviconResolver
225+ if resolver == nil {
226+ resolver = deps.FaviconResolver
227+ }
228+ if resolver != nil {
229+ faviconSetter.SetSystemviewFaviconResolver(resolver)
230+ }
225 }231 }
226 232
227 // Register message handlers through the engine.233 // Register message handlers through the engine.
@@ -1344,14 +1350,23 @@ func buildOmniboxConfig(
1344 }1350 }
1345 }1351 }
1346 1352
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+
1347 func NewStandaloneOmniboxRuntime(1361 func NewStandaloneOmniboxRuntime(
1348 ctx context.Context,1362 ctx context.Context,
1349 deps *Dependencies,1363 deps *Dependencies,
1350 faviconDB port.FaviconDatabase,1364 faviconDB port.FaviconDatabase,
1351 ) *StandaloneOmniboxRuntime {1365 ) *StandaloneOmniboxRuntime {
1352 var faviconAdapter *adapter.FaviconAdapter1366 var faviconAdapter *adapter.FaviconAdapter
1353- if deps != nil && deps.FaviconService != nil {1367+ if deps != nil && (deps.FaviconService != nil || deps.FaviconResolver != nil) {
1354- faviconAdapter = adapter.NewFaviconAdapter(deps.FaviconService, faviconDB, deps.FaviconAdapterConfig)1368+ faviconAdapter = adapter.NewFaviconAdapterWithResolver(deps.FaviconService, deps.FaviconResolver, faviconDB, deps.FaviconAdapterConfig)
1369+ registerFaviconInvalidator(deps.FaviconResolver, faviconAdapter)
1355 }1370 }
1356 1371
1357 omniboxCfg := buildOmniboxConfig(deps, faviconAdapter, omniboxCallbacks{1372 omniboxCfg := buildOmniboxConfig(deps, faviconAdapter, omniboxCallbacks{
@@ -3136,10 +3151,19 @@ func (a *App) initCoordinators(ctx context.Context) {
3136 return a.activeWorkspace(), a.activeWorkspaceView()3151 return a.activeWorkspace(), a.activeWorkspaceView()
3137 }3152 }
3138 3153
3139- // Create FaviconAdapter with service and engine FaviconDatabase.3154+ // Create FaviconAdapter with resolver/service and engine FaviconDatabase.
3140- // Skip if FaviconService is nil (e.g. in tests or when favicon support is disabled).3155+ // Skip if favicon support is not wired (e.g. in tests or when disabled).
3141- if a.deps.FaviconService != nil {3156+ if a.deps.FaviconService != nil || a.deps.FaviconResolver != nil {
3142- a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, a.engine.FaviconDatabase(), a.deps.FaviconAdapterConfig)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+ }
3143 }3167 }
3144 3168
3145 // 1. Content Coordinator (no dependencies on other coordinators)3169 // 1. Content Coordinator (no dependencies on other coordinators)
M internal/ui/coordinator/content/callbacks.go +1 -11
@@ -9,7 +9,6 @@ import (
9 "github.com/bnema/dumber/internal/application/usecase"9 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
12- "github.com/bnema/puregotk/v4/gdk"
13 )12 )
14 13
15 // internalSchemePath is the host used in dumb:// crash-page URIs.14 // internalSchemePath is the host used in dumb:// crash-page URIs.
@@ -122,16 +121,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
122 },121 },
123 }122 }
124 123
125- // Favicon changes - use Generation() from port.WebView to detect stale callbacks124+ c.setupFaviconCallbacks(ctx, paneID, wv, 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- }
135 125
136 // Middle-click link handler126 // Middle-click link handler
137 callbacks.OnLinkMiddleClick = func(uri string) bool {127 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 {
59 CopyURLUC *usecase.CopyURLUseCase59 CopyURLUC *usecase.CopyURLUseCase
60 60
61 // Infrastructure Adapters61 // Infrastructure Adapters
62- Clipboard port.Clipboard62+ Clipboard port.Clipboard
63- FaviconService port.FaviconService63+ FaviconService port.FaviconService
64- FaviconAdapterConfig adapter.FaviconAdapterConfig64+ FaviconResolver port.FaviconResolver
65- FilterManager port.FilterManager65+ SystemviewFaviconResolver port.FaviconSystemviewResolver
66- IdleInhibitor port.IdleInhibitor66+ FaviconAdapterConfig adapter.FaviconAdapterConfig
67+ FilterManager port.FilterManager
68+ IdleInhibitor port.IdleInhibitor
67 69
68 // Accent picker for dead keys support70 // Accent picker for dead keys support
69 InsertAccentUC *usecase.InsertAccentUseCase71 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