detached 1b29d2c

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

feat(favicon): refresh cache and resolve favicons
commit
1b29d2c1232c291c7d29ac5c3e404d7bf76795ba
author
bnema · · 2026-06-07 20:12 UTC
committer
GitHub · · 2026-06-07 20:12 UTC
merge of
5a2fbe6 + 4ac613d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
52 files · +3838 -363
M cmd/dumber/main.go +55 -23
@@ -26,7 +26,7 @@ import (
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 +441 -0
@@ -0,0 +1,441 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync"
8+ "time"
9+
10+ appport "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/favicon"
12+)
13+
14+var ErrFaviconMiss = appport.ErrFaviconMiss
15+
16+type ResolvePurpose = appport.FaviconResolvePurpose
17+
18+const (
19+ ResolvePurposeUI = appport.FaviconResolvePurposeUI
20+ ResolvePurposeSystemview = appport.FaviconResolvePurposeSystemview
21+ ResolvePurposeRefresh = appport.FaviconResolvePurposeRefresh
22+)
23+
24+type ResolveOptions = appport.FaviconResolveOptions
25+type ResolvedFavicon = appport.ResolvedFavicon
26+
27+// SystemviewIconSize is the fixed PNG size served to internal systemview pages.
28+const SystemviewIconSize = 32
29+
30+type faviconInvalidatorRegistry struct {
31+ mu sync.RWMutex
32+ fallback appport.FaviconInvalidators
33+ invalidators []appport.FaviconInvalidator
34+}
35+
36+func (r *faviconInvalidatorRegistry) Register(invalidator appport.FaviconInvalidator) {
37+ if r == nil || invalidator == nil {
38+ return
39+ }
40+ r.mu.Lock()
41+ defer r.mu.Unlock()
42+ r.invalidators = append(r.invalidators, invalidator)
43+}
44+
45+func (r *faviconInvalidatorRegistry) InvalidateAll(ctx context.Context, key favicon.Key) error {
46+ if r == nil {
47+ return nil
48+ }
49+ r.mu.RLock()
50+ fallback := r.fallback
51+ invalidators := append([]appport.FaviconInvalidator(nil), r.invalidators...)
52+ r.mu.RUnlock()
53+ // The fallback invalidator owns pre-registered invalidation sets and is
54+ // treated as fatal; dynamic invalidators below are best-effort and joined.
55+ if fallback != nil {
56+ if err := fallback.InvalidateAll(ctx, key); err != nil {
57+ return err
58+ }
59+ }
60+ var errs []error
61+ for _, invalidator := range invalidators {
62+ if err := invalidator.Invalidate(ctx, key); err != nil {
63+ errs = append(errs, err)
64+ }
65+ }
66+ return errors.Join(errs...)
67+}
68+
69+type FaviconUseCase struct {
70+ repo appport.FaviconRepository
71+ blobs appport.FaviconBlobStore
72+ fetcher appport.FaviconFetcher
73+ discoverer appport.FaviconDiscoverer
74+ converter appport.FaviconImageConverter
75+ scheduler appport.FaviconRefreshScheduler
76+ invalidators *faviconInvalidatorRegistry
77+ now func() time.Time
78+ ttl time.Duration
79+ background context.Context
80+}
81+
82+type FaviconDeps struct {
83+ Repository appport.FaviconRepository
84+ BlobStore appport.FaviconBlobStore
85+ Fetcher appport.FaviconFetcher
86+ Discoverer appport.FaviconDiscoverer
87+ Converter appport.FaviconImageConverter
88+ Scheduler appport.FaviconRefreshScheduler
89+ Invalidators appport.FaviconInvalidators
90+ Now func() time.Time
91+ TTL time.Duration
92+ Background context.Context
93+}
94+
95+func NewFaviconUseCase(deps FaviconDeps) *FaviconUseCase {
96+ if deps.Now == nil {
97+ deps.Now = time.Now
98+ }
99+ if deps.TTL == 0 {
100+ deps.TTL = favicon.DefaultTTL
101+ }
102+ if deps.Background == nil {
103+ deps.Background = context.Background()
104+ }
105+ registry := &faviconInvalidatorRegistry{}
106+ if deps.Invalidators != nil {
107+ registry.fallback = deps.Invalidators
108+ }
109+ return &FaviconUseCase{
110+ repo: deps.Repository,
111+ blobs: deps.BlobStore,
112+ fetcher: deps.Fetcher,
113+ discoverer: deps.Discoverer,
114+ converter: deps.Converter,
115+ scheduler: deps.Scheduler,
116+ invalidators: registry,
117+ now: deps.Now,
118+ ttl: deps.TTL,
119+ background: deps.Background,
120+ }
121+}
122+
123+func (uc *FaviconUseCase) RegisterFaviconInvalidator(invalidator appport.FaviconInvalidator) {
124+ if uc == nil || uc.invalidators == nil || invalidator == nil {
125+ return
126+ }
127+ uc.invalidators.Register(invalidator)
128+}
129+
130+func (uc *FaviconUseCase) Resolve(
131+ ctx context.Context,
132+ rawURLOrDomain string,
133+ size int,
134+ options ResolveOptions,
135+) (*appport.ResolvedFavicon, error) {
136+ keys := favicon.Candidates(rawURLOrDomain)
137+ if len(keys) == 0 {
138+ return nil, ErrFaviconMiss
139+ }
140+
141+ for _, key := range keys {
142+ if err := ctx.Err(); err != nil {
143+ return nil, err
144+ }
145+ meta, err := uc.repo.Get(ctx, key)
146+ if err != nil {
147+ return nil, err
148+ }
149+ if meta == nil {
150+ repaired, repairErr := uc.repairOrphanBlobMetadata(ctx, key, rawURLOrDomain, size)
151+ if repairErr != nil {
152+ if !errors.Is(repairErr, ErrFaviconMiss) {
153+ return nil, repairErr
154+ }
155+ continue
156+ }
157+ return repaired, nil
158+ }
159+ bytes, contentType, err := uc.readForSize(ctx, key, size)
160+ if err != nil {
161+ if !errors.Is(err, ErrFaviconMiss) {
162+ return nil, err
163+ }
164+ continue
165+ }
166+ res := &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: meta}
167+ if favicon.ShouldRefresh(meta, uc.now(), uc.ttl) && options.ScheduleBackgroundRefresh {
168+ res.BackgroundRefreshScheduled = uc.scheduleRefresh(key, meta.PageURL)
169+ }
170+ return res, nil
171+ }
172+
173+ if options.AllowBlockingRefresh {
174+ if err := uc.refresh(ctx, rawURLOrDomain, true); err != nil {
175+ return nil, err
176+ }
177+ return uc.Resolve(ctx, rawURLOrDomain, size, ResolveOptions{Purpose: options.Purpose})
178+ }
179+ if options.ScheduleBackgroundRefresh {
180+ uc.scheduleRefresh(keys[0], rawURLOrDomain)
181+ }
182+ return nil, ErrFaviconMiss
183+}
184+
185+func (uc *FaviconUseCase) ResolveSystemviewIcon(ctx context.Context, rawDomain string, size int) (*appport.ResolvedFavicon, error) {
186+ if size != SystemviewIconSize {
187+ return nil, ErrFaviconMiss
188+ }
189+ return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview})
190+}
191+
192+func (uc *FaviconUseCase) repairOrphanBlobMetadata(
193+ ctx context.Context,
194+ key favicon.Key,
195+ rawURLOrDomain string,
196+ size int,
197+) (*appport.ResolvedFavicon, error) {
198+ bytes, contentType, err := uc.readForSize(ctx, key, size)
199+ if err != nil {
200+ return nil, err
201+ }
202+ if contentType == "" {
203+ contentType = "image/png"
204+ }
205+ now := uc.now()
206+ meta := favicon.Metadata{
207+ Key: key,
208+ SourceURL: rawURLOrDomain,
209+ PageURL: rawURLOrDomain,
210+ Source: favicon.SourceImported,
211+ ContentHash: favicon.Hash(bytes),
212+ ContentType: contentType,
213+ UpdatedAt: now,
214+ LastCheckedAt: now,
215+ }
216+ if err := uc.repo.Upsert(ctx, meta); err != nil {
217+ return nil, err
218+ }
219+ return &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: &meta}, nil
220+}
221+
222+func (uc *FaviconUseCase) Observe(
223+ ctx context.Context,
224+ pageURL, iconURL string,
225+ bytes []byte,
226+ source favicon.Source,
227+ contentType string,
228+) (*favicon.Metadata, error) {
229+ key, ok := favicon.CanonicalKey(pageURL)
230+ if !ok || len(bytes) == 0 {
231+ return nil, ErrFaviconMiss
232+ }
233+ old, err := uc.repo.Get(ctx, key)
234+ if err != nil {
235+ return nil, err
236+ }
237+ now := uc.now()
238+ if !favicon.HasContentChanged(old, bytes) {
239+ if err := uc.repo.UpdateLastChecked(ctx, key, old.ContentHash, now); err != nil {
240+ return nil, err
241+ }
242+ old.LastCheckedAt = now
243+ return old, nil
244+ }
245+
246+ var converted *appport.ConvertedFavicon
247+ if uc.converter != nil {
248+ var err error
249+ converted, err = uc.converter.Convert(ctx, bytes, contentType, []int{SystemviewIconSize})
250+ if err != nil {
251+ return nil, err
252+ }
253+ }
254+
255+ if err := uc.blobs.WriteOriginal(ctx, key, bytes, contentType); err != nil {
256+ return nil, err
257+ }
258+ if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
259+ return nil, err
260+ }
261+ if converted != nil {
262+ if len(converted.PNG) > 0 {
263+ if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
264+ return nil, err
265+ }
266+ }
267+ if b := converted.SizedPNG[SystemviewIconSize]; len(b) > 0 {
268+ if err := uc.blobs.WriteSizedPNG(ctx, key, SystemviewIconSize, b); err != nil {
269+ return nil, err
270+ }
271+ }
272+ }
273+ meta := favicon.Metadata{
274+ Key: key,
275+ SourceURL: iconURL,
276+ PageURL: pageURL,
277+ Source: source,
278+ ContentHash: favicon.Hash(bytes),
279+ ContentType: contentType,
280+ UpdatedAt: now,
281+ LastCheckedAt: now,
282+ }
283+ if err := uc.repo.Upsert(ctx, meta); err != nil {
284+ return nil, err
285+ }
286+ if err := uc.invalidateCaches(ctx, key); err != nil {
287+ return nil, err
288+ }
289+ return &meta, nil
290+}
291+
292+func (uc *FaviconUseCase) RefreshIfStale(ctx context.Context, pageURL string) error {
293+ return uc.refresh(ctx, pageURL, false)
294+}
295+
296+func (uc *FaviconUseCase) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string) error {
297+ if uc.fetcher == nil {
298+ return ErrFaviconMiss
299+ }
300+ var lastMiss error
301+ for _, iconURL := range iconURLs {
302+ if err := ctx.Err(); err != nil {
303+ return err
304+ }
305+ fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL, IconURL: iconURL})
306+ if errors.Is(err, ErrFaviconMiss) {
307+ lastMiss = err
308+ continue
309+ }
310+ if err != nil {
311+ return err
312+ }
313+ _, err = uc.Observe(ctx, fetched.PageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
314+ if errors.Is(err, ErrFaviconMiss) {
315+ lastMiss = err
316+ continue
317+ }
318+ return err
319+ }
320+ if lastMiss != nil {
321+ return lastMiss
322+ }
323+ return uc.RefreshIfStale(ctx, pageURL)
324+}
325+
326+func (uc *FaviconUseCase) refresh(ctx context.Context, pageURL string, force bool) error {
327+ keys := favicon.Candidates(pageURL)
328+ if len(keys) == 0 {
329+ return ErrFaviconMiss
330+ }
331+ if !force {
332+ fresh, err := uc.anyCandidateFresh(ctx, keys)
333+ if err != nil {
334+ return err
335+ }
336+ if fresh {
337+ return nil
338+ }
339+ }
340+ return uc.fetchAndObserve(ctx, pageURL)
341+}
342+
343+func (uc *FaviconUseCase) refreshKey(ctx context.Context, key favicon.Key, pageURL string) error {
344+ meta, err := uc.repo.Get(ctx, key)
345+ if err != nil {
346+ return err
347+ }
348+ if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
349+ return nil
350+ }
351+ return uc.fetchAndObserve(ctx, pageURL)
352+}
353+
354+func (uc *FaviconUseCase) anyCandidateFresh(ctx context.Context, keys []favicon.Key) (bool, error) {
355+ for _, key := range keys {
356+ meta, err := uc.repo.Get(ctx, key)
357+ if err != nil {
358+ return false, err
359+ }
360+ if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
361+ return true, nil
362+ }
363+ }
364+ return false, nil
365+}
366+
367+func (uc *FaviconUseCase) fetchAndObserve(ctx context.Context, pageURL string) error {
368+ if uc.fetcher == nil {
369+ return ErrFaviconMiss
370+ }
371+ fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL})
372+ if err != nil {
373+ return err
374+ }
375+ _, err = uc.Observe(ctx, fetched.PageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
376+ return err
377+}
378+
379+func (uc *FaviconUseCase) EnsureSized(ctx context.Context, key favicon.Key, size int) error {
380+ if _, _, err := uc.blobs.ReadSizedPNG(ctx, key, size); err == nil {
381+ return nil
382+ } else if !errors.Is(err, ErrFaviconMiss) {
383+ return err
384+ }
385+ orig, ct, err := uc.blobs.ReadOriginal(ctx, key)
386+ if err != nil {
387+ return err
388+ }
389+ if uc.converter == nil {
390+ return ErrFaviconMiss
391+ }
392+ converted, err := uc.converter.Convert(ctx, orig, ct, []int{size})
393+ if err != nil {
394+ return err
395+ }
396+ if len(converted.PNG) > 0 {
397+ if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
398+ return err
399+ }
400+ }
401+ if b := converted.SizedPNG[size]; len(b) > 0 {
402+ return uc.blobs.WriteSizedPNG(ctx, key, size, b)
403+ }
404+ return ErrFaviconMiss
405+}
406+
407+func (uc *FaviconUseCase) Invalidate(ctx context.Context, key favicon.Key) error {
408+ if uc.blobs != nil {
409+ if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
410+ return err
411+ }
412+ }
413+ return uc.invalidateCaches(ctx, key)
414+}
415+
416+func (uc *FaviconUseCase) invalidateCaches(ctx context.Context, key favicon.Key) error {
417+ if uc.invalidators != nil {
418+ return uc.invalidators.InvalidateAll(ctx, key)
419+ }
420+ return nil
421+}
422+
423+func (uc *FaviconUseCase) readForSize(ctx context.Context, key favicon.Key, size int) ([]byte, string, error) {
424+ if size < 0 {
425+ return nil, "", fmt.Errorf("invalid favicon size: %d", size)
426+ }
427+ if size > 0 {
428+ if err := uc.EnsureSized(ctx, key, size); err != nil {
429+ return nil, "", err
430+ }
431+ return uc.blobs.ReadSizedPNG(ctx, key, size)
432+ }
433+ return uc.blobs.ReadPNG(ctx, key)
434+}
435+
436+func (uc *FaviconUseCase) scheduleRefresh(key favicon.Key, pageURL string) bool {
437+ if uc.scheduler == nil {
438+ return false
439+ }
440+ return uc.scheduler.Schedule(uc.background, key, func(ctx context.Context) { _ = uc.refreshKey(ctx, key, pageURL) })
441+}
A internal/application/usecase/favicon_test.go +532 -0
@@ -0,0 +1,532 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+ "time"
8+
9+ appport "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/favicon"
11+)
12+
13+func TestFaviconResolveExactHit(t *testing.T) {
14+ fx := newFaviconFixture()
15+ fx.seed("docs.example.com", []byte("png32"), false)
16+ got, err := fx.uc.Resolve(context.Background(), "https://docs.example.com/a", 32, ResolveOptions{Purpose: ResolvePurposeUI})
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if got.Key != "docs.example.com" || string(got.Bytes) != "png32" {
21+ t.Fatalf("unexpected resolve: %#v %q", got, got.Bytes)
22+ }
23+}
24+
25+func TestFaviconResolveParentFallback(t *testing.T) {
26+ fx := newFaviconFixture()
27+ fx.seed("example.com", []byte("parent"), false)
28+ got, err := fx.uc.Resolve(context.Background(), "https://docs.example.com/a", 32, ResolveOptions{Purpose: ResolvePurposeUI})
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ if got.Key != "example.com" || string(got.Bytes) != "parent" {
33+ t.Fatalf("unexpected fallback: %#v", got)
34+ }
35+}
36+
37+func TestFaviconResolveRepairsOrphanCanonicalBlobMetadata(t *testing.T) {
38+ fx := newFaviconFixture()
39+ fx.blobs.png["example.com"] = []byte("png")
40+ fx.blobs.sized["example.com"] = map[int][]byte{SystemviewIconSize: []byte("png32")}
41+
42+ got, err := fx.uc.ResolveSystemviewIcon(context.Background(), "example.com", SystemviewIconSize)
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ if got.Key != "example.com" || string(got.Bytes) != "png32" {
47+ t.Fatalf("unexpected resolve: %#v %q", got, got.Bytes)
48+ }
49+ meta := fx.repo.byKey["example.com"]
50+ if meta == nil {
51+ t.Fatal("expected metadata to be repaired")
52+ }
53+ if meta.Source != favicon.SourceImported || meta.ContentType != "image/png" || meta.ContentHash != favicon.Hash([]byte("png32")) {
54+ t.Fatalf("unexpected repaired metadata: %#v", meta)
55+ }
56+}
57+
58+func TestFaviconResolveRepairOrphanWithoutConverterReturnsMiss(t *testing.T) {
59+ fx := newFaviconFixture()
60+ fx.uc.converter = nil
61+ fx.blobs.original["example.com"] = []byte("original")
62+ fx.blobs.contentTypes["example.com"] = "image/png"
63+
64+ _, err := fx.uc.ResolveSystemviewIcon(context.Background(), "example.com", SystemviewIconSize)
65+ if !errors.Is(err, ErrFaviconMiss) {
66+ t.Fatalf("expected favicon miss, got %v", err)
67+ }
68+}
69+
70+func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) {
71+ fx := newFaviconFixture()
72+ fx.seed("example.com", []byte("old"), true)
73+ for i := 0; i < 2; i++ {
74+ got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{ScheduleBackgroundRefresh: true})
75+ if err != nil {
76+ t.Fatal(err)
77+ }
78+ if !got.BackgroundRefreshScheduled && i == 0 {
79+ t.Fatal("first stale resolve did not schedule refresh")
80+ }
81+ }
82+ if fx.scheduler.schedules != 1 {
83+ t.Fatalf("schedules=%d", fx.scheduler.schedules)
84+ }
85+ if fx.scheduler.usedCallerContext {
86+ t.Fatal("scheduler used caller context")
87+ }
88+}
89+
90+func TestFaviconResolveStaleExactRefreshIgnoresFreshParent(t *testing.T) {
91+ fx := newFaviconFixture()
92+ fx.seed("docs.example.com", []byte("old-exact"), true)
93+ fx.seed("example.com", []byte("fresh-parent"), false)
94+ got, err := fx.uc.Resolve(
95+ context.Background(),
96+ "https://docs.example.com/page",
97+ 32,
98+ ResolveOptions{ScheduleBackgroundRefresh: true},
99+ )
100+ if err != nil {
101+ t.Fatal(err)
102+ }
103+ if got.Key != "docs.example.com" || !got.BackgroundRefreshScheduled {
104+ t.Fatalf("unexpected resolve: %#v", got)
105+ }
106+ fx.scheduler.run("docs.example.com")
107+ if string(fx.blobs.original["docs.example.com"]) != "fetched" {
108+ t.Fatalf("exact key was not refreshed, originals=%v", fx.blobs.original)
109+ }
110+}
111+
112+func TestFaviconObserveSameHashUpdatesMetadataOnly(t *testing.T) {
113+ fx := newFaviconFixture()
114+ fx.seedOriginal([]byte("same"))
115+ if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://cdn.test/icon.png", []byte("same"), favicon.SourceEngine, "image/png"); err != nil {
116+ t.Fatal(err)
117+ }
118+ if fx.blobs.writeOriginals != 0 || fx.converter.calls != 0 || fx.invalidators.calls != 0 {
119+ t.Fatalf("rewrote/converted/invalidated same hash")
120+ }
121+ if fx.repo.byKey["example.com"].LastCheckedAt != fx.now {
122+ t.Fatal("last checked not updated")
123+ }
124+}
125+
126+func TestFaviconObserveChangedHashRegeneratesAndInvalidates(t *testing.T) {
127+ fx := newFaviconFixture()
128+ fx.seedOriginal([]byte("old"))
129+ if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://cdn.test/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); err != nil {
130+ t.Fatal(err)
131+ }
132+ if fx.blobs.writeOriginals != 1 || fx.converter.calls != 1 || fx.blobs.removeDerived != 1 || fx.invalidators.calls != 1 {
133+ t.Fatalf("writes=%d converts=%d remove=%d invalidates=%d", fx.blobs.writeOriginals, fx.converter.calls, fx.blobs.removeDerived, fx.invalidators.calls)
134+ }
135+ if string(fx.blobs.png["example.com"]) != "png:new" || string(fx.blobs.sized["example.com"][32]) != "png32:new" {
136+ t.Fatal("derived pngs not regenerated")
137+ }
138+ if fx.repo.byKey["example.com"].SourceURL != "https://cdn.test/icon.png" {
139+ t.Fatal("iconURL not stored as metadata")
140+ }
141+}
142+
143+func TestFaviconObserveWithoutConverterStoresOriginalOnly(t *testing.T) {
144+ fx := newFaviconFixture()
145+ fx.uc.converter = nil
146+ if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://example.com/icon.png", []byte("raw"), favicon.SourceEngine, "image/png"); err != nil {
147+ t.Fatal(err)
148+ }
149+ if string(fx.blobs.original["example.com"]) != "raw" {
150+ t.Fatalf("original not stored: %v", fx.blobs.original)
151+ }
152+ if len(fx.blobs.png) != 0 || len(fx.blobs.sized) != 0 {
153+ t.Fatalf("unexpected derivatives: png=%v sized=%v", fx.blobs.png, fx.blobs.sized)
154+ }
155+}
156+
157+func TestFaviconResolveMissingFetchesOnlyWhenBlockingAllowed(t *testing.T) {
158+ fx := newFaviconFixture()
159+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, ErrFaviconMiss) {
160+ t.Fatalf("default resolve err=%v", err)
161+ }
162+ if fx.fetcher.calls != 0 {
163+ t.Fatal("default resolve blocked on fetch")
164+ }
165+ got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true})
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+ if got.Key != "example.com" || fx.fetcher.calls != 1 {
170+ t.Fatalf("blocking resolve failed: %#v calls=%d", got, fx.fetcher.calls)
171+ }
172+}
173+
174+func TestFaviconRefreshIfStaleUsesParentFallbackBeforeFetching(t *testing.T) {
175+ fx := newFaviconFixture()
176+ fx.seed("example.com", []byte("parent"), false)
177+ if err := fx.uc.RefreshIfStale(context.Background(), "https://docs.example.com/page"); err != nil {
178+ t.Fatal(err)
179+ }
180+ if fx.fetcher.calls != 0 {
181+ t.Fatalf("fresh parent fallback should not fetch, calls=%d", fx.fetcher.calls)
182+ }
183+}
184+
185+func TestFaviconUseCasePropagatesRepositoryErrors(t *testing.T) {
186+ fx := newFaviconFixture()
187+ fx.repo.err = errors.New("database unavailable")
188+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, fx.repo.err) {
189+ t.Fatalf("Resolve err=%v, want %v", err, fx.repo.err)
190+ }
191+ if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://cdn.example/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); !errors.Is(err, fx.repo.err) {
192+ t.Fatalf("Observe err=%v, want %v", err, fx.repo.err)
193+ }
194+ if err := fx.uc.RefreshIfStale(context.Background(), "https://example.com"); !errors.Is(err, fx.repo.err) {
195+ t.Fatalf("RefreshIfStale err=%v, want %v", err, fx.repo.err)
196+ }
197+}
198+
199+func TestFaviconResolvePropagatesBlobErrors(t *testing.T) {
200+ fx := newFaviconFixture()
201+ fx.seed("example.com", []byte("cached"), false)
202+ fx.blobs.err = errors.New("disk unavailable")
203+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, fx.blobs.err) {
204+ t.Fatalf("Resolve err=%v, want %v", err, fx.blobs.err)
205+ }
206+}
207+
208+func TestFaviconResolveBlockingRefreshesWhenMetadataFreshButBlobMissing(t *testing.T) {
209+ fx := newFaviconFixture()
210+ fx.repo.byKey["example.com"] = &favicon.Metadata{Key: "example.com", PageURL: "https://example.com", ContentHash: favicon.Hash([]byte("old")), LastCheckedAt: fx.now}
211+ got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true})
212+ if err != nil {
213+ t.Fatal(err)
214+ }
215+ if got.Key != "example.com" || fx.fetcher.calls != 1 || string(got.Bytes) != "png32:fetched" {
216+ t.Fatalf("unexpected resolve after forced refresh: got=%#v calls=%d bytes=%q", got, fx.fetcher.calls, got.Bytes)
217+ }
218+}
219+
220+func TestFaviconResolveBlockingPropagatesFetchErrors(t *testing.T) {
221+ fx := newFaviconFixture()
222+ fx.fetcher.err = errors.New("network unavailable")
223+ if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true}); !errors.Is(err, fx.fetcher.err) {
224+ t.Fatalf("Resolve err=%v, want %v", err, fx.fetcher.err)
225+ }
226+}
227+
228+func TestFaviconRefreshFromIconURLsUsesPageURLKey(t *testing.T) {
229+ fx := newFaviconFixture()
230+ if err := fx.uc.RefreshFromIconURLs(context.Background(), "https://docs.example.com/page", []string{"https://cdn.example.net/icon.png"}); err != nil {
231+ t.Fatal(err)
232+ }
233+ if _, ok := fx.repo.byKey["docs.example.com"]; !ok {
234+ t.Fatalf("page URL key was not stored; keys=%v", fx.repo.byKey)
235+ }
236+ if _, ok := fx.repo.byKey["cdn.example.net"]; ok {
237+ t.Fatal("icon URL host should not be used as cache key")
238+ }
239+}
240+
241+func TestFaviconObserveConversionErrorDoesNotMutateExistingAssets(t *testing.T) {
242+ fx := newFaviconFixture()
243+ fx.seedOriginal([]byte("old"))
244+ fx.blobs.png["example.com"] = []byte("old-png")
245+ fx.blobs.sized["example.com"] = map[int][]byte{32: []byte("old-32")}
246+ fx.converter.err = errors.New("unsupported image")
247+
248+ if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://example.com/favicon.ico", []byte("bad"), favicon.SourceEngine, "image/svg+xml"); !errors.Is(err, fx.converter.err) {
249+ t.Fatalf("Observe err=%v, want %v", err, fx.converter.err)
250+ }
251+ if string(fx.blobs.original["example.com"]) != "old" || string(fx.blobs.png["example.com"]) != "old-png" || string(fx.blobs.sized["example.com"][32]) != "old-32" {
252+ t.Fatalf("existing assets mutated on conversion error")
253+ }
254+ if fx.blobs.writeOriginals != 0 || fx.blobs.removeDerived != 0 || fx.invalidators.calls != 0 {
255+ t.Fatalf("unexpected mutation counters writes=%d remove=%d invalidates=%d", fx.blobs.writeOriginals, fx.blobs.removeDerived, fx.invalidators.calls)
256+ }
257+}
258+
259+func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) {
260+ fx := newFaviconFixture()
261+ fx.seedOriginal([]byte("old"))
262+ fx.blobs.readSizedErr = errors.New("sized store unavailable")
263+ if err := fx.uc.EnsureSized(context.Background(), "example.com", 32); !errors.Is(err, fx.blobs.readSizedErr) {
264+ t.Fatalf("EnsureSized err=%v, want %v", err, fx.blobs.readSizedErr)
265+ }
266+ if fx.converter.calls != 0 {
267+ t.Fatal("converter called after non-miss ReadSizedPNG error")
268+ }
269+}
270+
271+type faviconFixture struct {
272+ repo *fakeFaviconRepo
273+ blobs *fakeBlobStore
274+ converter *fakeConverter
275+ scheduler *fakeScheduler
276+ invalidators *fakeInvalidators
277+ fetcher *fakeFetcher
278+ uc *FaviconUseCase
279+ now time.Time
280+}
281+
282+type faviconTestContextKey string
283+
284+const faviconFixtureBackgroundKey faviconTestContextKey = "bg"
285+
286+func newFaviconFixture() *faviconFixture {
287+ now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
288+ fx := &faviconFixture{
289+ repo: newFakeRepo(),
290+ blobs: newFakeBlobStore(),
291+ converter: &fakeConverter{},
292+ scheduler: &fakeScheduler{seen: map[favicon.Key]bool{}},
293+ invalidators: &fakeInvalidators{},
294+ fetcher: &fakeFetcher{},
295+ now: now,
296+ }
297+ fx.uc = NewFaviconUseCase(FaviconDeps{
298+ Repository: fx.repo,
299+ BlobStore: fx.blobs,
300+ Converter: fx.converter,
301+ Scheduler: fx.scheduler,
302+ Invalidators: fx.invalidators,
303+ Fetcher: fx.fetcher,
304+ Now: func() time.Time { return fx.now },
305+ Background: context.WithValue(context.Background(), faviconFixtureBackgroundKey, true),
306+ })
307+ return fx
308+}
309+func (f *faviconFixture) seed(key string, sized []byte, stale bool) {
310+ checked := f.now
311+ if stale {
312+ checked = f.now.Add(-favicon.DefaultTTL - time.Hour)
313+ }
314+ f.repo.byKey[favicon.Key(key)] = &favicon.Metadata{Key: favicon.Key(key), PageURL: "https://" + key, ContentHash: favicon.Hash([]byte("orig")), LastCheckedAt: checked}
315+ f.blobs.sized[favicon.Key(key)] = map[int][]byte{32: sized}
316+}
317+func (f *faviconFixture) seedOriginal(b []byte) {
318+ const contentType = "image/png"
319+ key := favicon.Key("example.com")
320+ f.repo.byKey[key] = &favicon.Metadata{
321+ Key: key,
322+ PageURL: "https://example.com",
323+ ContentHash: favicon.Hash(b),
324+ ContentType: contentType,
325+ LastCheckedAt: f.now.Add(-time.Hour),
326+ }
327+ f.blobs.original[key] = b
328+ f.blobs.contentTypes[key] = contentType
329+}
330+
331+type fakeFaviconRepo struct {
332+ byKey map[favicon.Key]*favicon.Metadata
333+ err error
334+}
335+
336+func newFakeRepo() *fakeFaviconRepo {
337+ return &fakeFaviconRepo{byKey: map[favicon.Key]*favicon.Metadata{}}
338+}
339+func (r *fakeFaviconRepo) Get(_ context.Context, key favicon.Key) (*favicon.Metadata, error) {
340+ if r.err != nil {
341+ return nil, r.err
342+ }
343+ if m := r.byKey[key]; m != nil {
344+ cp := *m
345+ return &cp, nil
346+ }
347+ return nil, nil
348+}
349+func (r *fakeFaviconRepo) FindFirst(ctx context.Context, keys []favicon.Key) (*favicon.Metadata, error) {
350+ if r.err != nil {
351+ return nil, r.err
352+ }
353+ for _, k := range keys {
354+ if m, _ := r.Get(ctx, k); m != nil {
355+ return m, nil
356+ }
357+ }
358+ return nil, nil
359+}
360+func (r *fakeFaviconRepo) Upsert(_ context.Context, meta favicon.Metadata) error {
361+ if r.err != nil {
362+ return r.err
363+ }
364+ cp := meta
365+ r.byKey[meta.Key] = &cp
366+ return nil
367+}
368+func (r *fakeFaviconRepo) UpdateLastChecked(_ context.Context, key favicon.Key, hash string, checkedAt time.Time) error {
369+ if r.err != nil {
370+ return r.err
371+ }
372+ r.byKey[key].ContentHash = hash
373+ r.byKey[key].LastCheckedAt = checkedAt
374+ return nil
375+}
376+func (r *fakeFaviconRepo) Delete(_ context.Context, key favicon.Key) error {
377+ if r.err != nil {
378+ return r.err
379+ }
380+ delete(r.byKey, key)
381+ return nil
382+}
383+
384+type fakeBlobStore struct {
385+ original, png map[favicon.Key][]byte
386+ sized map[favicon.Key]map[int][]byte
387+ contentTypes map[favicon.Key]string
388+ writeOriginals, removeDerived int
389+ err error
390+ readSizedErr error
391+}
392+
393+func newFakeBlobStore() *fakeBlobStore {
394+ return &fakeBlobStore{original: map[favicon.Key][]byte{}, png: map[favicon.Key][]byte{}, sized: map[favicon.Key]map[int][]byte{}, contentTypes: map[favicon.Key]string{}}
395+}
396+func (b *fakeBlobStore) ReadOriginal(_ context.Context, key favicon.Key) ([]byte, string, error) {
397+ if b.err != nil {
398+ return nil, "", b.err
399+ }
400+ v := b.original[key]
401+ if v == nil {
402+ return nil, "", ErrFaviconMiss
403+ }
404+ return v, b.contentTypes[key], nil
405+}
406+func (b *fakeBlobStore) WriteOriginal(_ context.Context, key favicon.Key, data []byte, ct string) error {
407+ if b.err != nil {
408+ return b.err
409+ }
410+ b.writeOriginals++
411+ b.original[key] = data
412+ b.contentTypes[key] = ct
413+ return nil
414+}
415+func (b *fakeBlobStore) ReadPNG(_ context.Context, key favicon.Key) ([]byte, string, error) {
416+ if b.err != nil {
417+ return nil, "", b.err
418+ }
419+ v := b.png[key]
420+ if v == nil {
421+ return nil, "", ErrFaviconMiss
422+ }
423+ return v, "image/png", nil
424+}
425+func (b *fakeBlobStore) WritePNG(_ context.Context, key favicon.Key, data []byte) error {
426+ if b.err != nil {
427+ return b.err
428+ }
429+ b.png[key] = data
430+ return nil
431+}
432+func (b *fakeBlobStore) ReadSizedPNG(_ context.Context, key favicon.Key, size int) ([]byte, string, error) {
433+ if b.err != nil {
434+ return nil, "", b.err
435+ }
436+ if b.readSizedErr != nil {
437+ return nil, "", b.readSizedErr
438+ }
439+ if b.sized[key] == nil || b.sized[key][size] == nil {
440+ return nil, "", ErrFaviconMiss
441+ }
442+ return b.sized[key][size], "image/png", nil
443+}
444+func (b *fakeBlobStore) WriteSizedPNG(_ context.Context, key favicon.Key, size int, data []byte) error {
445+ if b.err != nil {
446+ return b.err
447+ }
448+ if b.sized[key] == nil {
449+ b.sized[key] = map[int][]byte{}
450+ }
451+ b.sized[key][size] = data
452+ return nil
453+}
454+func (b *fakeBlobStore) RemoveDerived(_ context.Context, key favicon.Key) error {
455+ if b.err != nil {
456+ return b.err
457+ }
458+ b.removeDerived++
459+ delete(b.png, key)
460+ delete(b.sized, key)
461+ return nil
462+}
463+
464+type fakeConverter struct {
465+ calls int
466+ err error
467+}
468+
469+func (c *fakeConverter) Convert(_ context.Context, original []byte, _ string, sizes []int) (*appport.ConvertedFavicon, error) {
470+ c.calls++
471+ if c.err != nil {
472+ return nil, c.err
473+ }
474+ out := &appport.ConvertedFavicon{PNG: append([]byte("png:"), original...), SizedPNG: map[int][]byte{}}
475+ for _, s := range sizes {
476+ out.SizedPNG[s] = append([]byte("png32:"), original...)
477+ }
478+ return out, nil
479+}
480+
481+type fakeScheduler struct {
482+ seen map[favicon.Key]bool
483+ works map[favicon.Key]func(context.Context)
484+ schedules int
485+ usedCallerContext bool
486+}
487+
488+func (s *fakeScheduler) Schedule(ctx context.Context, key favicon.Key, work func(context.Context)) bool {
489+ if ctx.Value(faviconFixtureBackgroundKey) != true {
490+ s.usedCallerContext = true
491+ }
492+ if s.seen[key] {
493+ return false
494+ }
495+ if s.works == nil {
496+ s.works = map[favicon.Key]func(context.Context){}
497+ }
498+ s.seen[key] = true
499+ s.works[key] = work
500+ s.schedules++
501+ return true
502+}
503+
504+func (s *fakeScheduler) run(key favicon.Key) {
505+ if work := s.works[key]; work != nil {
506+ work(context.Background())
507+ }
508+}
509+
510+type fakeInvalidators struct{ calls int }
511+
512+func (i *fakeInvalidators) InvalidateAll(_ context.Context, _ favicon.Key) error {
513+ i.calls++
514+ return nil
515+}
516+
517+type fakeFetcher struct {
518+ calls int
519+ err error
520+}
521+
522+func (f *fakeFetcher) Fetch(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
523+ f.calls++
524+ if f.err != nil {
525+ return nil, f.err
526+ }
527+ iconURL := req.IconURL
528+ if iconURL == "" {
529+ iconURL = "https://example.com/favicon.ico"
530+ }
531+ return &appport.FaviconFetchedIcon{PageURL: req.PageURL, IconURL: iconURL, Bytes: []byte("fetched"), Source: favicon.SourceDuckDuckGo, ContentType: "image/png"}, nil
532+}
A internal/domain/favicon/key.go +132 -0
@@ -0,0 +1,132 @@
1+package favicon
2+
3+import (
4+ "net"
5+ "net/url"
6+ "slices"
7+ "strings"
8+
9+ "golang.org/x/net/idna"
10+ "golang.org/x/net/publicsuffix"
11+)
12+
13+type Key string
14+
15+func CanonicalKey(raw string) (Key, bool) {
16+ candidates := Candidates(raw)
17+ if len(candidates) == 0 {
18+ return "", false
19+ }
20+ return candidates[0], true
21+}
22+
23+func Candidates(raw string) []Key {
24+ host, port, hasScheme, scheme, ok := parseHost(raw)
25+ if !ok {
26+ return nil
27+ }
28+
29+ host = normalizeHost(host)
30+ if host == "" {
31+ return nil
32+ }
33+
34+ if isExactHost(host) {
35+ if port != "" && (!hasScheme || !isDefaultPort(scheme, port)) {
36+ return []Key{Key(net.JoinHostPort(host, port))}
37+ }
38+ return []Key{Key(host)}
39+ }
40+
41+ ascii, err := idna.Lookup.ToASCII(host)
42+ if err != nil {
43+ return nil
44+ }
45+ host = strings.ToLower(strings.TrimSuffix(ascii, "."))
46+ host = stripWWW(host)
47+
48+ if port != "" {
49+ if !hasScheme || !isDefaultPort(scheme, port) {
50+ return []Key{Key(net.JoinHostPort(host, port))}
51+ }
52+ }
53+
54+ labels := strings.Split(host, ".")
55+ if slices.Contains(labels, "") {
56+ return nil
57+ }
58+
59+ registrable, err := publicsuffix.EffectiveTLDPlusOne(host)
60+ if err != nil {
61+ return []Key{Key(host)}
62+ }
63+
64+ var out []Key
65+ for i := range len(labels) {
66+ candidate := strings.Join(labels[i:], ".")
67+ out = append(out, Key(candidate))
68+ if candidate == registrable {
69+ break
70+ }
71+ }
72+ return out
73+}
74+
75+func parseHost(raw string) (host, port string, hasScheme bool, scheme string, ok bool) {
76+ raw = strings.TrimSpace(raw)
77+ if raw == "" {
78+ return "", "", false, "", false
79+ }
80+
81+ if strings.Contains(raw, "://") {
82+ u, err := url.Parse(raw)
83+ if err != nil || u.Scheme == "" {
84+ return "", "", false, "", false
85+ }
86+ scheme = strings.ToLower(u.Scheme)
87+ if scheme != "http" && scheme != "https" {
88+ return "", "", true, scheme, false
89+ }
90+ host = u.Hostname()
91+ port = u.Port()
92+ return host, port, true, scheme, host != ""
93+ }
94+ if strings.HasPrefix(strings.ToLower(raw), "about:") {
95+ return "", "", false, "", false
96+ }
97+
98+ hostport := raw
99+ if cut := strings.IndexAny(hostport, "/?#"); cut >= 0 {
100+ hostport = hostport[:cut]
101+ }
102+ hostport = strings.TrimSuffix(hostport, ".")
103+
104+ if h, p, err := net.SplitHostPort(hostport); err == nil {
105+ return h, p, false, "", h != ""
106+ }
107+ if strings.Count(hostport, ":") == 1 {
108+ if h, p, ok := strings.Cut(hostport, ":"); ok && h != "" && p != "" {
109+ return h, p, false, "", true
110+ }
111+ }
112+ return hostport, "", false, "", hostport != ""
113+}
114+
115+func normalizeHost(host string) string {
116+ host = strings.TrimSpace(host)
117+ host = strings.Trim(host, "[]")
118+ host = strings.TrimSuffix(host, ".")
119+ return strings.ToLower(host)
120+}
121+
122+func stripWWW(host string) string {
123+ return strings.TrimPrefix(host, "www.")
124+}
125+
126+func isDefaultPort(scheme, port string) bool {
127+ return (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
128+}
129+
130+func isExactHost(host string) bool {
131+ return host == "localhost" || net.ParseIP(host) != nil
132+}
A internal/domain/favicon/key_test.go +57 -0
@@ -0,0 +1,57 @@
1+package favicon
2+
3+import "testing"
4+
5+func TestKeyCandidates(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ raw string
9+ want []Key
10+ }{
11+ {name: "walks public host to registrable domain", raw: "https://app.docs.example.com/path", want: []Key{"app.docs.example.com", "docs.example.com", "example.com"}},
12+ {name: "strips www", raw: "https://www.example.com", want: []Key{"example.com"}},
13+ {name: "localhost with port is exact", raw: "http://localhost:1455/path", want: []Key{"localhost:1455"}},
14+ {name: "ip with port is exact", raw: "http://127.0.0.1:8080/path", want: []Key{"127.0.0.1:8080"}},
15+ {name: "ipv6 with port is exact", raw: "http://[::1]:8080/path", want: []Key{"[::1]:8080"}},
16+ {name: "uses public suffix for co uk", raw: "https://news.bbc.co.uk/story", want: []Key{"news.bbc.co.uk", "bbc.co.uk"}},
17+
18+ {name: "drops default https port", raw: "https://example.com:443/path", want: []Key{"example.com"}},
19+ {name: "non default url port is exact", raw: "https://example.com:8443/path", want: []Key{"example.com:8443"}},
20+ {name: "host-like string lowercases and trims trailing dot", raw: "Docs.Example.COM.", want: []Key{"docs.example.com", "example.com"}},
21+ {name: "strips www before preserving non default port", raw: "https://www.example.com:8443", want: []Key{"example.com:8443"}},
22+ {name: "host-like string with port preserves port exact", raw: "example.com:443", want: []Key{"example.com:443"}},
23+ {name: "unicode host normalizes to punycode", raw: "https://bücher.example/path", want: []Key{"xn--bcher-kva.example"}},
24+ {name: "punycode equivalent matches unicode", raw: "https://xn--bcher-kva.example/path", want: []Key{"xn--bcher-kva.example"}},
25+ {name: "empty input has no candidates", raw: "", want: nil},
26+ {name: "about url has no candidates", raw: "about:blank", want: nil},
27+ {name: "unsupported scheme has no candidates", raw: "file:///tmp/icon.html", want: nil},
28+ }
29+
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ got := Candidates(tt.raw)
33+ if len(got) != len(tt.want) {
34+ t.Fatalf("Candidates(%q) len = %d (%v), want %d (%v)", tt.raw, len(got), got, len(tt.want), tt.want)
35+ }
36+ for i := range got {
37+ if got[i] != tt.want[i] {
38+ t.Fatalf("Candidates(%q)[%d] = %q, want %q (all got %v)", tt.raw, i, got[i], tt.want[i], got)
39+ }
40+ }
41+ })
42+ }
43+}
44+
45+func TestCanonicalKey(t *testing.T) {
46+ key, ok := CanonicalKey("https://www.Example.COM.:443/path")
47+ if !ok {
48+ t.Fatal("CanonicalKey returned false")
49+ }
50+ if key != "example.com" {
51+ t.Fatalf("CanonicalKey = %q, want example.com", key)
52+ }
53+
54+ if key, ok := CanonicalKey("about:blank"); ok || key != "" {
55+ t.Fatalf("CanonicalKey(about:blank) = %q, %v; want empty false", key, ok)
56+ }
57+}
A internal/domain/favicon/metadata.go +25 -0
@@ -0,0 +1,25 @@
1+package favicon
2+
3+import "time"
4+
5+type Source string
6+
7+const (
8+ SourceEngine Source = "engine"
9+ SourcePageDiscovery Source = "page_discovery"
10+ SourceDuckDuckGo Source = "duckduckgo"
11+ SourceInternal Source = "internal"
12+ SourceImported Source = "imported"
13+ SourceManual Source = "manual"
14+)
15+
16+type Metadata struct {
17+ Key Key
18+ SourceURL string
19+ PageURL string
20+ Source Source
21+ ContentHash string
22+ ContentType string
23+ UpdatedAt time.Time
24+ LastCheckedAt time.Time
25+}
A internal/domain/favicon/policy.go +31 -0
@@ -0,0 +1,31 @@
1+package favicon
2+
3+import (
4+ "crypto/sha256"
5+ "encoding/hex"
6+ "time"
7+)
8+
9+const DefaultTTL = 30 * 24 * time.Hour
10+
11+func Hash(data []byte) string {
12+ sum := sha256.Sum256(data)
13+ return hex.EncodeToString(sum[:])
14+}
15+
16+func ShouldRefresh(meta *Metadata, now time.Time, ttl time.Duration) bool {
17+ if meta == nil {
18+ return true
19+ }
20+ if ttl <= 0 {
21+ return true
22+ }
23+ return !meta.LastCheckedAt.Add(ttl).After(now)
24+}
25+
26+func HasContentChanged(meta *Metadata, data []byte) bool {
27+ if meta == nil {
28+ return true
29+ }
30+ return meta.ContentHash != Hash(data)
31+}
A internal/domain/favicon/policy_test.go +42 -0
@@ -0,0 +1,42 @@
1+package favicon
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestShouldRefresh(t *testing.T) {
9+ now := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
10+ ttl := 30 * 24 * time.Hour
11+
12+ if !ShouldRefresh(nil, now, ttl) {
13+ t.Fatal("missing metadata should refresh")
14+ }
15+
16+ fresh := &Metadata{LastCheckedAt: now.Add(-ttl + time.Minute)}
17+ if ShouldRefresh(fresh, now, ttl) {
18+ t.Fatal("fresh metadata should not refresh")
19+ }
20+
21+ stale := &Metadata{LastCheckedAt: now.Add(-ttl - time.Minute)}
22+ if !ShouldRefresh(stale, now, ttl) {
23+ t.Fatal("stale metadata should refresh")
24+ }
25+}
26+
27+func TestContentHashChanged(t *testing.T) {
28+ data := []byte("favicon bytes")
29+ meta := &Metadata{ContentHash: Hash(data)}
30+
31+ if HasContentChanged(meta, data) {
32+ t.Fatal("same bytes should not be changed")
33+ }
34+
35+ if !HasContentChanged(meta, []byte("different bytes")) {
36+ t.Fatal("different bytes should be changed")
37+ }
38+
39+ if !HasContentChanged(nil, data) {
40+ t.Fatal("missing metadata should be treated as changed")
41+ }
42+}
A internal/infrastructure/cef/favicon_discovery.go +145 -0
@@ -0,0 +1,145 @@
1+package cef
2+
3+import (
4+ "net/url"
5+ "strings"
6+
7+ "golang.org/x/net/html"
8+)
9+
10+// DiscoverFaviconCandidates extracts ordered favicon URL candidates from page
11+// metadata and appends the origin /favicon.ico fallback. Only http(s) page and
12+// icon URLs are returned; duplicates are removed while preserving order.
13+func DiscoverFaviconCandidates(pageURL, htmlSource string) []string {
14+ candidates := make([]string, 0, 4)
15+ for _, href := range parseFaviconHrefs(htmlSource) {
16+ if resolved, ok := resolveFaviconURL(pageURL, href); ok {
17+ candidates = append(candidates, resolved)
18+ }
19+ }
20+ if fallback, ok := originFaviconURL(pageURL); ok {
21+ candidates = append(candidates, fallback)
22+ }
23+ if len(candidates) == 0 {
24+ return nil
25+ }
26+ return dedupeStrings(candidates)
27+}
28+
29+func resolveFaviconCandidates(pageURL string, rawIconURLs []string) []string {
30+ candidates := make([]string, 0, len(rawIconURLs))
31+ for _, raw := range rawIconURLs {
32+ if resolved, ok := resolveFaviconURL(pageURL, raw); ok {
33+ candidates = append(candidates, resolved)
34+ }
35+ }
36+ return dedupeStrings(candidates)
37+}
38+
39+func fallbackFaviconCandidates(pageURL string) []string {
40+ fallback, ok := originFaviconURL(pageURL)
41+ if !ok {
42+ return nil
43+ }
44+ return []string{fallback}
45+}
46+
47+func parseFaviconHrefs(htmlSource string) []string {
48+ z := html.NewTokenizer(strings.NewReader(htmlSource))
49+ var hrefs []string
50+ for {
51+ switch z.Next() {
52+ case html.ErrorToken:
53+ return hrefs
54+ case html.StartTagToken, html.SelfClosingTagToken:
55+ tn, hasAttr := z.TagName()
56+ if !hasAttr || !strings.EqualFold(string(tn), "link") {
57+ continue
58+ }
59+ var rel, href string
60+ for {
61+ key, val, more := z.TagAttr()
62+ switch strings.ToLower(string(key)) {
63+ case "rel":
64+ rel = string(val)
65+ case "href":
66+ href = string(val)
67+ }
68+ if !more {
69+ break
70+ }
71+ }
72+ if href != "" && faviconRel(rel) {
73+ hrefs = append(hrefs, href)
74+ }
75+ }
76+ }
77+}
78+
79+func faviconRel(rel string) bool {
80+ fields := strings.Fields(strings.ToLower(rel))
81+ for _, field := range fields {
82+ if field == "icon" || field == "apple-touch-icon" || field == "apple-touch-icon-precomposed" {
83+ return true
84+ }
85+ }
86+ return false
87+}
88+
89+func resolveFaviconURL(pageURL, rawIconURL string) (string, bool) {
90+ trimmed := strings.TrimSpace(rawIconURL)
91+ if trimmed == "" {
92+ return "", false
93+ }
94+ icon, err := url.Parse(trimmed)
95+ if err != nil {
96+ return "", false
97+ }
98+ if icon.IsAbs() {
99+ if !isHTTPScheme(icon.Scheme) || icon.Host == "" {
100+ return "", false
101+ }
102+ return icon.String(), true
103+ }
104+ base, err := url.Parse(pageURL)
105+ if err != nil || !isHTTPScheme(base.Scheme) || base.Host == "" {
106+ return "", false
107+ }
108+ resolved := base.ResolveReference(icon)
109+ if !isHTTPScheme(resolved.Scheme) || resolved.Host == "" {
110+ return "", false
111+ }
112+ return resolved.String(), true
113+}
114+
115+func originFaviconURL(pageURL string) (string, bool) {
116+ u, err := url.Parse(pageURL)
117+ if err != nil || !isHTTPScheme(u.Scheme) || u.Host == "" {
118+ return "", false
119+ }
120+ u.Path = "/favicon.ico"
121+ u.RawPath = ""
122+ u.RawQuery = ""
123+ u.Fragment = ""
124+ return u.String(), true
125+}
126+
127+func isHTTPScheme(scheme string) bool {
128+ return scheme == "http" || scheme == "https"
129+}
130+
131+func dedupeStrings(values []string) []string {
132+ seen := make(map[string]struct{}, len(values))
133+ out := make([]string, 0, len(values))
134+ for _, value := range values {
135+ if value == "" {
136+ continue
137+ }
138+ if _, ok := seen[value]; ok {
139+ continue
140+ }
141+ seen[value] = struct{}{}
142+ out = append(out, value)
143+ }
144+ return out
145+}
A internal/infrastructure/cef/favicon_discovery_test.go +108 -0
@@ -0,0 +1,108 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ purecef "github.com/bnema/purego-cef/cef"
9+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestFaviconDiscoveryResolvesLinksAndFallback(t *testing.T) {
15+ pageURL := "https://example.com/docs/page.html"
16+ html := `<html><head>
17+ <link rel="stylesheet" href="/style.css">
18+ <link rel="icon" href="/favicon.png">
19+ <link rel="shortcut icon" href="icons/shortcut.ico">
20+ <link rel="apple-touch-icon" href="https://cdn.example.com/apple.png">
21+ <link rel="icon" href="javascript:alert(1)">
22+ </head></html>`
23+
24+ got := DiscoverFaviconCandidates(pageURL, html)
25+ require.Equal(t, []string{
26+ "https://example.com/favicon.png",
27+ "https://example.com/docs/icons/shortcut.ico",
28+ "https://cdn.example.com/apple.png",
29+ "https://example.com/favicon.ico",
30+ }, got)
31+}
32+
33+func TestFaviconDiscoveryFallbackOnly(t *testing.T) {
34+ require.Equal(t, []string{"https://example.com/favicon.ico"}, DiscoverFaviconCandidates("https://example.com/path", "<html></html>"))
35+ require.Nil(t, DiscoverFaviconCandidates("about:blank", "<html></html>"))
36+}
37+
38+func TestFaviconDiscoveryKeepsAbsoluteIconsForOpaquePageURL(t *testing.T) {
39+ got := DiscoverFaviconCandidates("about:blank", `<link rel="icon" href="https://cdn.example.com/icon.png">`)
40+ require.Equal(t, []string{"https://cdn.example.com/icon.png"}, got)
41+}
42+
43+func TestFaviconDiscoveryIgnoresUnsafeIconURLs(t *testing.T) {
44+ got := DiscoverFaviconCandidates("https://example.com/path", `<link rel="icon" href="data:image/png;base64,AAAA"><link rel="icon" href="//cdn.example.com/icon.png">`)
45+ require.Equal(t, []string{"https://cdn.example.com/icon.png", "https://example.com/favicon.ico"}, got)
46+}
47+
48+func TestFaviconDiscoveryIgnoresWhitespaceOnlyHrefs(t *testing.T) {
49+ got := DiscoverFaviconCandidates("https://example.com/path", `<link rel="icon" href=" ">`)
50+ require.Equal(t, []string{"https://example.com/favicon.ico"}, got)
51+}
52+
53+func TestCEFOnFaviconUrlchangeForwardsOrderedCandidates(t *testing.T) {
54+ prevDecode := decodeCEFStringList
55+ decodeCEFStringList = func(list purecef.StringList) []string {
56+ require.Equal(t, purecef.StringList(42), list)
57+ return []string{"/favicon.png", "https://cdn.example.com/icon.ico", "javascript:alert(1)"}
58+ }
59+ defer func() { decodeCEFStringList = prevDecode }()
60+
61+ var page string
62+ var icons []string
63+ wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{
64+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
65+ }}
66+
67+ (&handlerSet{wv: wv}).OnFaviconUrlchange(nil, purecef.StringList(42))
68+
69+ require.Equal(t, "https://example.com/docs/page", page)
70+ require.Equal(t, []string{"https://example.com/favicon.png", "https://cdn.example.com/icon.ico"}, icons)
71+}
72+
73+func TestCEFLoadingFinishedDiscoversMetadataBeforeFallback(t *testing.T) {
74+ prevNewStringVisitor := cefNewStringVisitor
75+ cefNewStringVisitor = func(visitor purecef.StringVisitor) purecef.StringVisitor { return visitor }
76+ defer func() { cefNewStringVisitor = prevNewStringVisitor }()
77+
78+ browser := cefmocks.NewMockBrowser(t)
79+ frame := cefmocks.NewMockFrame(t)
80+ browser.EXPECT().GetMainFrame().Return(frame)
81+ frame.EXPECT().GetSource(mock.Anything).Run(func(visitor purecef.StringVisitor) {
82+ visitor.Visit(`<html><head><link rel="icon" href="/meta.png"></head></html>`)
83+ }).Return()
84+
85+ var page string
86+ var icons []string
87+ wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{
88+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
89+ }}
90+
91+ (&handlerSet{wv: wv}).OnLoadingStateChange(browser, 0, 1, 0)
92+
93+ require.Equal(t, "https://example.com/docs/page", page)
94+ require.Equal(t, []string{"https://example.com/meta.png", "https://example.com/favicon.ico"}, icons)
95+}
96+
97+func TestCEFLoadingFinishedForwardsFallbackCandidate(t *testing.T) {
98+ var page string
99+ var icons []string
100+ wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{
101+ OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls },
102+ }}
103+
104+ (&handlerSet{wv: wv}).OnLoadingStateChange(nil, 0, 1, 0)
105+
106+ require.Equal(t, "https://example.com/docs/page", page)
107+ require.Equal(t, []string{"https://example.com/favicon.ico"}, icons)
108+}
M internal/infrastructure/cef/favicon_systemviews.go +25 -7
@@ -1,15 +1,33 @@
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 +41 -114
@@ -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,29 @@ 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 {
840+ reqCtx, cancel := context.WithCancel(ctx) //nolint:gosec // canceled by load completion or CEF Cancel callback
837 return cefNewResourceHandler(&faviconResourceHandler{841 return cefNewResourceHandler(&faviconResourceHandler{
838- service: service,842+ ctx: reqCtx,
839- domain: domain,843+ cancel: cancel,
840- size: size,844+ resolver: resolver,
841- done: make(chan struct{}),845+ domain: domain,
842- headers: map[string]string{"Cache-Control": "no-store"},846+ size: size,
847+ done: make(chan struct{}),
848+ headers: map[string]string{"Cache-Control": "no-store"},
843 })849 })
844 }850 }
845 851
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 {852 type faviconResourceHandler struct {
920- service port.FaviconService853+ ctx context.Context
854+ cancel context.CancelFunc
855+ cancelOnce sync.Once
856+ resolver port.FaviconSystemviewResolver
921 domain string857 domain string
922 size int858 size int
923 headers map[string]string859 headers map[string]string
@@ -931,38 +867,23 @@ type faviconResourceHandler struct {
931 867
932 func (rh *faviconResourceHandler) load() {868 func (rh *faviconResourceHandler) load() {
933 defer close(rh.done)869 defer close(rh.done)
870+ defer rh.cancelRequest()
934 rh.statusCode = http.StatusNotFound871 rh.statusCode = http.StatusNotFound
935 rh.contentType = "application/json"872 rh.contentType = "application/json"
936 rh.data = []byte(`{"error":"favicon not cached"}`)873 rh.data = []byte(`{"error":"favicon not cached"}`)
937- if rh.service == nil {874+ 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 return875 return
949 }876 }
950- diskPath := rh.service.DiskPathPNGSized(rh.domain, rh.size)877+ resolved, err := rh.resolver.ResolveSystemviewIcon(rh.ctx, rh.domain, rh.size)
951- if diskPath == "" {878+ 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 return879 return
957 }880 }
958 rh.statusCode = http.StatusOK881 rh.statusCode = http.StatusOK
959- rh.contentType = "image/png"882+ rh.contentType = resolved.ContentType
960- rh.data = data883+ if rh.contentType == "" {
961- systemviewFaviconCache.put(cacheKey, faviconCacheEntry{884+ rh.contentType = "image/png"
962- statusCode: rh.statusCode,885+ }
963- contentType: rh.contentType,886+ rh.data = resolved.Bytes
964- data: data,
965- })
966 }887 }
967 888
968 func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {889 func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {
@@ -1038,7 +959,13 @@ func (rh *faviconResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int
1038 return 0959 return 0
1039 }960 }
1040 961
1041-func (rh *faviconResourceHandler) Cancel() {}962+func (rh *faviconResourceHandler) Cancel() { rh.cancelRequest() }
963+
964+func (rh *faviconResourceHandler) cancelRequest() {
965+ if rh.cancel != nil {
966+ rh.cancelOnce.Do(rh.cancel)
967+ }
968+}
1042 969
1043 // Open handles the request immediately (synchronous).970 // Open handles the request immediately (synchronous).
1044 func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 {971 func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 {
M internal/infrastructure/cef/scheme_handler_test.go +5 -18
@@ -10,6 +10,8 @@ import (
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 +114 -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,79 @@ 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+ continue
392+ }
393+ last := len(wv.faviconSourceVisitors) - 1
394+ copy(wv.faviconSourceVisitors[i:], wv.faviconSourceVisitors[i+1:])
395+ wv.faviconSourceVisitors[last] = faviconSourceVisitorRetention{}
396+ wv.faviconSourceVisitors = wv.faviconSourceVisitors[:last]
397+ return
398+ }
399+ }
400+}
401+
402+func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pageURL string, cb *port.WebViewCallbacks) {
403+ if h == nil || h.wv == nil || cb == nil || cb.OnFaviconURLChanged == nil {
404+ return
405+ }
406+ fallback := fallbackFaviconCandidates(pageURL)
407+ emit := func(candidates []string) {
408+ if len(candidates) == 0 {
409+ return
410+ }
411+ h.wv.runOnGTK(func() {
412+ cb.OnFaviconURLChanged(pageURL, candidates)
413+ })
414+ }
415+ if browser == nil {
416+ emit(fallback)
417+ return
418+ }
419+ frame := browser.GetMainFrame()
420+ if frame == nil {
421+ emit(fallback)
422+ return
423+ }
424+ var releaseOnce sync.Once
425+ var release func()
426+ visitor := cefNewStringVisitor(faviconSourceVisitor{visit: func(source string) {
427+ releaseOnce.Do(func() {
428+ if release != nil {
429+ release()
430+ }
431+ })
432+ candidates := DiscoverFaviconCandidates(pageURL, source)
433+ if len(candidates) == 0 {
434+ candidates = fallback
435+ }
436+ emit(candidates)
437+ }})
438+ release = h.wv.retainFaviconSourceVisitor(visitor)
439+ time.AfterFunc(5*time.Second, func() {
440+ releaseOnce.Do(func() {
441+ if release != nil {
442+ release()
443+ }
444+ })
445+ })
446+ frame.GetSource(visitor)
447+}
448+
337 // OnLoadStart fires LoadCommitted for main frame navigations.449 // OnLoadStart fires LoadCommitted for main frame navigations.
338 // In CEF this callback runs after navigation commit, so it is the closest450 // In CEF this callback runs after navigation commit, so it is the closest
339 // equivalent to WebKit's LoadCommitted event.451 // 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 +18 -70
@@ -15,24 +15,24 @@ 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- }31+func (s *Service) Cache() *Cache {
32+ return s.cache
32 }33 }
33 34
34-// Get returns favicon bytes for a domain.35+// 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) {36 func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
37 if domain == "" {37 if domain == "" {
38 return nil, nil38 return nil, nil
@@ -46,7 +46,6 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
46 return assets.LogoSVG, nil46 return assets.LogoSVG, nil
47 }47 }
48 48
49- // Check cache first (memory + disk)
50 if data, ok := s.cache.Get(ctx, domain); ok {49 if data, ok := s.cache.Get(ctx, domain); ok {
51 log.Debug().50 log.Debug().
52 Str("domain", domain).51 Str("domain", domain).
@@ -54,24 +53,8 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) {
54 Msg("favicon: Service.Get cache hit")53 Msg("favicon: Service.Get cache hit")
55 return data, nil54 return data, nil
56 }55 }
57-56+ log.Debug().Str("domain", domain).Msg("favicon: Service.Get cache miss")
58- // Fetch from external API57+ 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 }58 }
76 59
77 // IsInternalURL checks if a URL uses the internal dumb:// scheme.60 // IsInternalURL checks if a URL uses the internal dumb:// scheme.
@@ -127,35 +110,10 @@ func (s *Service) Close() {
127 s.cache.Close()110 s.cache.Close()
128 }111 }
129 112
130-// EnsureDiskCache fetches favicon from external API and saves to disk if not already cached.113+// 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.114+// texture/blob writes after EnsureCacheDir; remote refresh policy belongs to the application usecase.
132-func (s *Service) EnsureDiskCache(ctx context.Context, domain string) {115+func (*Service) EnsureDiskCache(ctx context.Context, domain string) {
133- if domain == "" {116+ 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 }117 }
160 118
161 // DiskPathPNGSized returns the filesystem path for a sized PNG favicon.119 // DiskPathPNGSized returns the filesystem path for a sized PNG favicon.
@@ -168,8 +126,8 @@ func (s *Service) HasPNGSizedOnDisk(domain string, size int) bool {
168 return s.cache.HasPNGSizedOnDisk(domain, size)126 return s.cache.HasPNGSizedOnDisk(domain, size)
169 }127 }
170 128
171-// EnsureSizedPNG creates a resized PNG from the original if it doesn't exist.129+// EnsureSizedPNG creates or overwrites a resized PNG from the original PNG.
172-// This is used to generate normalized icons for dmenu/fuzzel.130+// 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 {131 func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) error {
174 if domain == "" {132 if domain == "" {
175 return nil133 return nil
@@ -180,16 +138,6 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e
180 Int("size", size).138 Int("size", size).
181 Msg("favicon: EnsureSizedPNG begin")139 Msg("favicon: EnsureSizedPNG begin")
182 140
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)141 srcPath := s.DiskPathPNG(domain)
194 if srcPath == "" || !s.HasPNGOnDisk(domain) {142 if srcPath == "" || !s.HasPNGOnDisk(domain) {
195 return fmt.Errorf("source PNG not found for domain %s", domain)143 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 +18 -17
@@ -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"
@@ -73,9 +71,10 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
73 71
74 // DumbSchemeHandler handles dumb:// URI scheme requests.72 // DumbSchemeHandler handles dumb:// URI scheme requests.
75 type DumbSchemeHandler struct {73 type DumbSchemeHandler struct {
74+ ctx context.Context
76 handlers map[string]PageHandler75 handlers map[string]PageHandler
77 assets embed.FS76 assets embed.FS
78- faviconService port.FaviconService77+ faviconResolver port.FaviconSystemviewResolver
79 assetDir string // default subdirectory within embed.FS (e.g., "systemviews")78 assetDir string // default subdirectory within embed.FS (e.g., "systemviews")
80 logger zerolog.Logger79 logger zerolog.Logger
81 mu sync.RWMutex80 mu sync.RWMutex
@@ -88,6 +87,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
88 log := logging.FromContext(ctx)87 log := logging.FromContext(ctx)
89 88
90 h := &DumbSchemeHandler{89 h := &DumbSchemeHandler{
90+ ctx: ctx,
91 handlers: make(map[string]PageHandler),91 handlers: make(map[string]PageHandler),
92 assetDir: systemviewsAssetDir,92 assetDir: systemviewsAssetDir,
93 logger: log.With().Str("component", "scheme-handler").Logger(),93 logger: log.With().Str("component", "scheme-handler").Logger(),
@@ -116,10 +116,10 @@ func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload fun
116 h.logger.Debug().Msg("config payload builders configured")116 h.logger.Debug().Msg("config payload builders configured")
117 }117 }
118 118
119-func (h *DumbSchemeHandler) SetFaviconService(service port.FaviconService) {119+func (h *DumbSchemeHandler) SetFaviconResolver(resolver port.FaviconSystemviewResolver) {
120 h.mu.Lock()120 h.mu.Lock()
121 defer h.mu.Unlock()121 defer h.mu.Unlock()
122- h.faviconService = service122+ h.faviconResolver = resolver
123 }123 }
124 124
125 // registerDefaults sets up default page handlers.125 // registerDefaults sets up default page handlers.
@@ -210,9 +210,9 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
210 return privateJSONErrorResponse(http.StatusForbidden, "forbidden")210 return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
211 }211 }
212 h.mu.RLock()212 h.mu.RLock()
213- service := h.faviconService213+ resolver := h.faviconResolver
214 h.mu.RUnlock()214 h.mu.RUnlock()
215- if service == nil {215+ if resolver == nil {
216 return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable")216 return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable")
217 }217 }
218 if req == nil {218 if req == nil {
@@ -222,7 +222,7 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
222 if err != nil {222 if err != nil {
223 return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL")223 return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL")
224 }224 }
225- domain := domainurl.CanonicalDomain(parsed.Query().Get("domain"))225+ domain := strings.TrimSpace(parsed.Query().Get("domain"))
226 if domain == "" {226 if domain == "" {
227 return privateJSONErrorResponse(http.StatusBadRequest, "missing domain")227 return privateJSONErrorResponse(http.StatusBadRequest, "missing domain")
228 }228 }
@@ -235,20 +235,21 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse
235 size = parsedSize235 size = parsedSize
236 }236 }
237 237
238- if !service.HasPNGSizedOnDisk(domain, size) {238+ resolveCtx := h.ctx
239- return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")239+ if resolveCtx == nil {
240+ resolveCtx = context.Background()
240 }241 }
241- diskPath := service.DiskPathPNGSized(domain, size)242+ resolved, err := resolver.ResolveSystemviewIcon(resolveCtx, domain, size)
242- if diskPath == "" {243+ if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
243 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")244 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
244 }245 }
245- data, err := os.ReadFile(diskPath)246+ contentType := resolved.ContentType
246- if err != nil || len(data) == 0 {247+ if contentType == "" {
247- return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")248+ contentType = "image/png"
248 }249 }
249 return &SchemeResponse{250 return &SchemeResponse{
250- Data: data,251+ Data: resolved.Bytes,
251- ContentType: "image/png",252+ ContentType: contentType,
252 StatusCode: http.StatusOK,253 StatusCode: http.StatusOK,
253 Headers: map[string]string{"Cache-Control": "no-store"},254 Headers: map[string]string{"Cache-Control": "no-store"},
254 SuppressDefaultHeaders: true,255 SuppressDefaultHeaders: true,
M internal/ui/adapter/favicon.go +200 -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,8 +20,10 @@ 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
26+ aliasesByKey map[favicon.Key]map[string]struct{}
24 mu sync.RWMutex27 mu sync.RWMutex
25 warnMu sync.Mutex28 warnMu sync.Mutex
26 warnCounts map[string]int29 warnCounts map[string]int
@@ -57,11 +60,22 @@ type FaviconAdapterConfig struct {
57 // NewFaviconAdapter creates a new FaviconAdapter.60 // NewFaviconAdapter creates a new FaviconAdapter.
58 // The faviconDB can be nil if a favicon database is not available.61 // The faviconDB can be nil if a favicon database is not available.
59 func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {62 func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {
63+ return NewFaviconAdapterWithResolver(service, nil, faviconDB, cfg)
64+}
65+
66+func NewFaviconAdapterWithResolver(
67+ service port.FaviconService,
68+ resolver port.FaviconResolver,
69+ faviconDB port.FaviconDatabase,
70+ cfg FaviconAdapterConfig,
71+) *FaviconAdapter {
60 disabled := os.Getenv(disableFaviconsEnv) == "1"72 disabled := os.Getenv(disableFaviconsEnv) == "1"
61 return &FaviconAdapter{73 return &FaviconAdapter{
62 service: service,74 service: service,
75+ resolver: resolver,
63 faviconDB: faviconDB,76 faviconDB: faviconDB,
64 textureCache: make(map[string]*gdk.Texture),77 textureCache: make(map[string]*gdk.Texture),
78+ aliasesByKey: make(map[favicon.Key]map[string]struct{}),
65 warnCounts: make(map[string]int),79 warnCounts: make(map[string]int),
66 isInternalURL: cfg.IsInternalURL,80 isInternalURL: cfg.IsInternalURL,
67 internalDomain: cfg.InternalDomain,81 internalDomain: cfg.InternalDomain,
@@ -132,26 +146,34 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
132 return146 return
133 }147 }
134 148
149+ // Check application resolver before falling back to the legacy service.
150+ if texture := a.resolveTexture(ctx, pageURL, true); texture != nil {
151+ callback(texture)
152+ return
153+ }
154+
135 // Check service cache (memory + disk) and convert to texture155 // Check service cache (memory + disk) and convert to texture
136- if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {156+ if a.service != nil {
137- log.Debug().157+ if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
138- Str("domain", domain).158+ log.Debug().
139- Int("bytes", len(data)).159+ Str("domain", domain).
140- Msg("favicon: service cache hit")160+ Int("bytes", len(data)).
141- texture := a.textureFromBytesOnGTK(ctx, data)161+ Msg("favicon: service cache hit")
142- if texture != nil {162+ texture := a.textureFromBytesOnGTK(ctx, data)
143- a.setTexture(domain, texture)163+ if texture != nil {
144- // Ensure sized PNG exists for CLI tools (async, idempotent)164+ a.setTexture(domain, texture)
145- go a.ensureSizedPNG(ctx, domain)165+ // Ensure sized PNG exists for CLI tools (async, idempotent)
146- callback(texture)166+ go a.ensureSizedPNG(ctx, domain)
147- return167+ callback(texture)
168+ return
169+ }
148 }170 }
149 }171 }
150 172
151 // Try engine FaviconDatabase if available173 // Try engine FaviconDatabase if available
152 if a.faviconDB != nil {174 if a.faviconDB != nil {
153 a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {175 a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {
154- a.handleEngineFaviconDBTexture(ctx, domain, callback, tex)176+ a.handleEngineFaviconDBTexture(ctx, pageURL, domain, callback, tex)
155 })177 })
156 return178 return
157 }179 }
@@ -162,6 +184,9 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
162 }184 }
163 185
164 func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string, callback func(*gdk.Texture), texture *gdk.Texture) {186 func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string, callback func(*gdk.Texture), texture *gdk.Texture) {
187+ if callback == nil {
188+ return
189+ }
165 log := logging.FromContext(ctx)190 log := logging.FromContext(ctx)
166 log.Debug().191 log.Debug().
167 Str("domain", domain).192 Str("domain", domain).
@@ -178,7 +203,49 @@ func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string,
178 glib.IdleAdd(&cb, 0)203 glib.IdleAdd(&cb, 0)
179 }204 }
180 205
206+func (a *FaviconAdapter) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string, callback func(*gdk.Texture)) {
207+ domain := domainurl.ExtractDomain(pageURL)
208+ if a.resolver == nil {
209+ a.invokeCallbackOnGTK(ctx, domain, callback, nil)
210+ return
211+ }
212+ go func() {
213+ if err := a.resolver.RefreshFromIconURLs(ctx, pageURL, iconURLs); err != nil {
214+ logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: RefreshFromIconURLs failed")
215+ }
216+ if callback == nil {
217+ return
218+ }
219+ a.GetOrFetch(ctx, pageURL, func(texture *gdk.Texture) {
220+ a.invokeCallbackOnGTK(ctx, domain, callback, texture)
221+ })
222+ }()
223+}
224+
225+func (a *FaviconAdapter) resolveTexture(ctx context.Context, pageURL string, scheduleRefresh bool) *gdk.Texture {
226+ if a.resolver == nil {
227+ return nil
228+ }
229+ options := port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI}
230+ if scheduleRefresh {
231+ options.ScheduleBackgroundRefresh = true
232+ }
233+ resolved, err := a.resolver.Resolve(ctx, pageURL, 0, options)
234+ if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
235+ return nil
236+ }
237+ texture := a.textureFromBytesOnGTK(ctx, resolved.Bytes)
238+ if texture != nil {
239+ a.setResolvedTexture(pageURL, resolved.Key, texture)
240+ }
241+ return texture
242+}
243+
181 func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) {244 func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) {
245+ if a.service == nil {
246+ a.invokeCallbackOnGTK(ctx, domain, callback, nil)
247+ return
248+ }
182 go func() {249 go func() {
183 log := logging.FromContext(ctx)250 log := logging.FromContext(ctx)
184 log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin")251 log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin")
@@ -209,7 +276,12 @@ func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, cal
209 }()276 }()
210 }277 }
211 278
212-func (a *FaviconAdapter) handleEngineFaviconDBTexture(ctx context.Context, domain string, callback func(*gdk.Texture), tex port.Texture) {279+func (a *FaviconAdapter) handleEngineFaviconDBTexture(
280+ ctx context.Context,
281+ pageURL, domain string,
282+ callback func(*gdk.Texture),
283+ tex port.Texture,
284+) {
213 log := logging.FromContext(ctx)285 log := logging.FromContext(ctx)
214 log.Debug().286 log.Debug().
215 Str("domain", domain).287 Str("domain", domain).
@@ -228,7 +300,7 @@ func (a *FaviconAdapter) handleEngineFaviconDBTexture(ctx context.Context, domai
228 }300 }
229 a.setTexture(domain, gdkTex)301 a.setTexture(domain, gdkTex)
230 a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex)302 a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex)
231- a.saveFaviconToDisk(ctx, domain, gdkTex)303+ a.storeTextureObservation(ctx, pageURL, gdkTex)
232 }304 }
233 305
234 // StoreFromWebKit stores a favicon texture received from WebKit signals.306 // StoreFromWebKit stores a favicon texture received from WebKit signals.
@@ -252,7 +324,12 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te
252 // Store in texture cache324 // Store in texture cache
253 a.setTexture(domain, texture)325 a.setTexture(domain, texture)
254 326
255- // Export as PNG and ensure disk cache327+ if a.resolver != nil {
328+ a.storeTextureObservation(ctx, pageURL, texture)
329+ return
330+ }
331+
332+ // Legacy fallback for callers not yet wired to the application resolver.
256 a.saveFaviconToDisk(ctx, domain, texture)333 a.saveFaviconToDisk(ctx, domain, texture)
257 }334 }
258 335
@@ -269,14 +346,55 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin(
269 currentDomain := domainurl.ExtractDomain(currentURL)346 currentDomain := domainurl.ExtractDomain(currentURL)
270 if originDomain != "" && originDomain != currentDomain {347 if originDomain != "" && originDomain != currentDomain {
271 a.setTexture(originDomain, texture)348 a.setTexture(originDomain, texture)
272- a.saveFaviconToDisk(ctx, originDomain, texture)349+ if a.resolver != nil {
350+ a.storeTextureObservation(ctx, originURL, texture)
351+ } else {
352+ a.saveFaviconToDisk(ctx, originDomain, texture)
353+ }
273 }354 }
274 }355 }
275 }356 }
276 357
358+func (a *FaviconAdapter) storeTextureObservation(ctx context.Context, pageURL string, texture *gdk.Texture) {
359+ if a.resolver == nil || texture == nil || pageURL == "" {
360+ return
361+ }
362+ cb := glib.SourceFunc(func(_ uintptr) bool {
363+ tmp, err := os.CreateTemp("", "dumber-favicon-*.png")
364+ if err != nil {
365+ logging.FromContext(ctx).Debug().Err(err).Msg("favicon: failed to create temp PNG for observation")
366+ return false
367+ }
368+ path := tmp.Name()
369+ _ = tmp.Close()
370+ defer func() { _ = os.Remove(path) }()
371+ if ok := texture.SaveToPng(path); !ok {
372+ logging.FromContext(ctx).Debug().Str("page_url", pageURL).Msg("favicon: failed to export texture for observation")
373+ return false
374+ }
375+ data, err := os.ReadFile(path)
376+ if err != nil || len(data) == 0 {
377+ logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: failed to read exported observation")
378+ return false
379+ }
380+ go func() {
381+ // Engine-provided textures do not expose the original icon URL, so pageURL
382+ // is passed as the observation iconURL fallback for SourceEngine records.
383+ if _, err := a.resolver.Observe(ctx, pageURL, pageURL, data, favicon.SourceEngine, "image/png"); err != nil {
384+ logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: failed to observe engine texture")
385+ }
386+ }()
387+ return false
388+ })
389+ glib.IdleAdd(&cb, 0)
390+}
391+
277 // saveFaviconToDisk exports a favicon texture to PNG and ensures disk cache is populated.392 // 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.393 // 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) {394 func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) {
395+ if a.service == nil {
396+ return
397+ }
280 log := logging.FromContext(ctx)398 log := logging.FromContext(ctx)
281 cacheDirWarnKey := "cache-dir"399 cacheDirWarnKey := "cache-dir"
282 savePNGWarnKey := "save-png:" + domain400 savePNGWarnKey := "save-png:" + domain
@@ -359,16 +477,23 @@ func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *
359 return texture477 return texture
360 }478 }
361 479
480+ // Check application resolver before falling back to the legacy service.
481+ if texture := a.resolveTexture(ctx, pageURL, false); texture != nil {
482+ return texture
483+ }
484+
362 // Check service cache (memory + disk)485 // Check service cache (memory + disk)
363- if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {486+ if a.service != nil {
364- log.Debug().487+ if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 {
365- Str("domain", domain).488+ log.Debug().
366- Int("bytes", len(data)).489+ Str("domain", domain).
367- Msg("favicon: PreloadFromCache service hit")490+ Int("bytes", len(data)).
368- texture := a.textureFromBytesOnGTK(ctx, data)491+ Msg("favicon: PreloadFromCache service hit")
369- if texture != nil {492+ texture := a.textureFromBytesOnGTK(ctx, data)
370- a.setTexture(domain, texture)493+ if texture != nil {
371- return texture494+ a.setTexture(domain, texture)
495+ return texture
496+ }
372 }497 }
373 }498 }
374 499
@@ -384,13 +509,29 @@ func (a *FaviconAdapter) Service() port.FaviconService {
384 509
385 // Close shuts down the adapter and underlying service.510 // Close shuts down the adapter and underlying service.
386 func (a *FaviconAdapter) Close() {511 func (a *FaviconAdapter) Close() {
387- a.service.Close()512+ if a.service != nil {
513+ a.service.Close()
514+ }
515+}
516+
517+// Invalidate clears one resolved-key texture cache entry and all domain aliases
518+// explicitly bound to that canonical favicon key.
519+func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error {
520+ a.mu.Lock()
521+ delete(a.textureCache, string(key))
522+ for alias := range a.aliasesByKey[key] {
523+ delete(a.textureCache, alias)
524+ }
525+ delete(a.aliasesByKey, key)
526+ a.mu.Unlock()
527+ return nil
388 }528 }
389 529
390 // Clear removes all entries from the texture cache.530 // Clear removes all entries from the texture cache.
391 func (a *FaviconAdapter) Clear() {531 func (a *FaviconAdapter) Clear() {
392 a.mu.Lock()532 a.mu.Lock()
393 a.textureCache = make(map[string]*gdk.Texture)533 a.textureCache = make(map[string]*gdk.Texture)
534+ a.aliasesByKey = make(map[favicon.Key]map[string]struct{})
394 a.mu.Unlock()535 a.mu.Unlock()
395 }536 }
396 537
@@ -402,6 +543,37 @@ func (a *FaviconAdapter) Size() int {
402 return size543 return size
403 }544 }
404 545
546+// setResolvedTexture stores resolver results under both the resolved favicon key
547+// and the current page domain. Aliases are indexed by canonical key so
548+// invalidation does not depend on pointer equality with the latest texture.
549+func (a *FaviconAdapter) setResolvedTexture(pageURL string, key favicon.Key, texture *gdk.Texture) {
550+ if key == "" || texture == nil {
551+ return
552+ }
553+ domain := domainurl.ExtractDomain(pageURL)
554+ a.mu.Lock()
555+ defer a.mu.Unlock()
556+ a.textureCache[string(key)] = texture
557+ if domain == "" {
558+ return
559+ }
560+ a.removeAliasLocked(domain)
561+ a.textureCache[domain] = texture
562+ if a.aliasesByKey[key] == nil {
563+ a.aliasesByKey[key] = make(map[string]struct{})
564+ }
565+ a.aliasesByKey[key][domain] = struct{}{}
566+}
567+
568+func (a *FaviconAdapter) removeAliasLocked(alias string) {
569+ for key, aliases := range a.aliasesByKey {
570+ delete(aliases, alias)
571+ if len(aliases) == 0 {
572+ delete(a.aliasesByKey, key)
573+ }
574+ }
575+}
576+
405 // setTexture stores a texture in the cache.577 // setTexture stores a texture in the cache.
406 func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {578 func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
407 if domain == "" || texture == nil {579 if domain == "" || texture == nil {
@@ -415,7 +587,7 @@ func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) {
415 // ensureSizedPNG ensures the sized PNG exists for CLI tools.587 // ensureSizedPNG ensures the sized PNG exists for CLI tools.
416 // This is idempotent - it only creates the file if it doesn't exist.588 // This is idempotent - it only creates the file if it doesn't exist.
417 func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {589 func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
418- if domain == "" {590+ if domain == "" || a.service == nil {
419 return591 return
420 }592 }
421 593
M internal/ui/adapter/favicon_test.go +32 -0
@@ -1,12 +1,44 @@
1 package adapter1 package adapter
2 2
3 import (3 import (
4+ "context"
4 "sync/atomic"5 "sync/atomic"
5 "testing"6 "testing"
6 7
8+ "github.com/bnema/dumber/internal/domain/favicon"
9+ "github.com/bnema/puregotk/v4/gdk"
7 "github.com/rs/zerolog"10 "github.com/rs/zerolog"
8 )11 )
9 12
13+func TestFaviconAdapterInvalidateRemovesResolvedTextureAliases(t *testing.T) {
14+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
15+ texture := &gdk.Texture{}
16+ adapter.setResolvedTexture("https://docs.example.com/page", favicon.Key("example.com"), texture)
17+
18+ if adapter.GetTexture("example.com") == nil || adapter.GetTexture("docs.example.com") == nil {
19+ t.Fatal("expected both resolved key and domain alias to be cached")
20+ }
21+ if err := adapter.Invalidate(context.Background(), "example.com"); err != nil {
22+ t.Fatal(err)
23+ }
24+ if adapter.GetTexture("example.com") != nil || adapter.GetTexture("docs.example.com") != nil {
25+ t.Fatal("expected invalidation to remove resolved key and alias")
26+ }
27+}
28+
29+func TestFaviconAdapterInvalidateRemovesOlderAliasesAfterKeyRebind(t *testing.T) {
30+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
31+ adapter.setResolvedTexture("https://docs.example.com/page", favicon.Key("example.com"), &gdk.Texture{})
32+ adapter.setResolvedTexture("https://blog.example.com/page", favicon.Key("example.com"), &gdk.Texture{})
33+
34+ if err := adapter.Invalidate(context.Background(), "example.com"); err != nil {
35+ t.Fatal(err)
36+ }
37+ if adapter.GetTexture("example.com") != nil || adapter.GetTexture("docs.example.com") != nil || adapter.GetTexture("blog.example.com") != nil {
38+ t.Fatal("expected invalidation to remove canonical key and all aliases")
39+ }
40+}
41+
10 func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {42 func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {
11 adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})43 adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
12 44
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