feat(favicon): add cache refresh resolution pipeline
- commit
- 8a0ce09ffd1c41b35c6fd1dd7fb78231039e0233
- author
- brice · · 2026-06-07 18:17 UTC
- parent
- 5a2fbe6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
51 files · +3665 -362
M cmd/dumber/main.go +55 -23
| @@ -26,7 +26,7 @@ import ( | |||
| 26 | 26 | "github.com/bnema/dumber/internal/infrastructure/deps" | |
| 27 | 27 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 28 | 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 | 30 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| 31 | 31 | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| 32 | 32 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| @@ -688,6 +688,7 @@ type repositories struct { | |||
| 688 | 688 | permission port.PermissionRepository | |
| 689 | 689 | session repository.SessionRepository | |
| 690 | 690 | sessionState repository.SessionStateRepository | |
| 691 | + | faviconRepo port.FaviconRepository | |
| 691 | 692 | } | |
| 692 | 693 | ||
| 693 | 694 | func createRepositories(db *sql.DB) *repositories { | |
| @@ -700,6 +701,7 @@ func createRepositories(db *sql.DB) *repositories { | |||
| 700 | 701 | permission: sqlite.NewPermissionRepository(db), | |
| 701 | 702 | session: sqlite.NewSessionRepository(db), | |
| 702 | 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 | 715 | permission: sqlite.NewLazyPermissionRepository(provider), | |
| 714 | 716 | session: sqlite.NewLazySessionRepository(provider), | |
| 715 | 717 | sessionState: sqlite.NewLazySessionStateRepository(provider), | |
| 718 | + | faviconRepo: sqlite.NewLazyFaviconRepository(provider), | |
| 716 | 719 | } | |
| 717 | 720 | } | |
| 718 | 721 | ||
| @@ -731,7 +734,8 @@ type useCases struct { | |||
| 731 | 734 | checkUpdate *usecase.CheckUpdateUseCase | |
| 732 | 735 | applyUpdate *usecase.ApplyUpdateUseCase | |
| 733 | 736 | clipboard port.Clipboard | |
| 734 | - | favicon *favicon.Service | |
| 737 | + | favicon *infrafavicon.Service | |
| 738 | + | faviconUC *usecase.FaviconUseCase | |
| 735 | 739 | } | |
| 736 | 740 | ||
| 737 | 741 | func createUseCases(repos *repositories, cfg *config.Config) *useCases { | |
| @@ -744,6 +748,17 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases { | |||
| 744 | 748 | ||
| 745 | 749 | clipboardAdapter := clipboard.New() | |
| 746 | 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 | 762 | xdgDirs, _ := config.GetXDGDirs() | |
| 748 | 763 | stateDir, _ := config.GetStateDir() | |
| 749 | 764 | defaultZoom := cfg.DefaultWebpageZoom | |
| @@ -776,7 +791,8 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases { | |||
| 776 | 791 | checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo), | |
| 777 | 792 | applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, xdgDirs.CacheHome), | |
| 778 | 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 | 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 | 832 | func buildUIDependencies( | |
| 803 | 833 | ctx context.Context, | |
| 804 | 834 | cfg *config.Config, | |
| @@ -839,27 +869,29 @@ func buildUIDependencies( | |||
| 839 | 869 | runtimeProfile.Mode == runtimeprofile.ModeDev, | |
| 840 | 870 | bootstrap.ResolveXDGRuntimeDir(runtimeProfile), | |
| 841 | 871 | ), | |
| 842 | - | Engine: engine, | |
| 843 | - | FilterManager: filterManager, | |
| 844 | - | HistoryRepo: repos.history, | |
| 845 | - | FavoriteRepo: repos.favorite, | |
| 846 | - | ZoomRepo: repos.zoom, | |
| 847 | - | PermissionRepo: repos.permission, | |
| 848 | - | TabsUC: uc.tabs, | |
| 849 | - | PanesUC: uc.panes, | |
| 850 | - | HistoryUC: uc.history, | |
| 851 | - | FavoritesUC: uc.favorites, | |
| 852 | - | ZoomUC: uc.zoom, | |
| 853 | - | PermissionUC: uc.permission, | |
| 854 | - | NavigateUC: uc.navigate, | |
| 855 | - | CopyURLUC: uc.copyURL, | |
| 856 | - | Clipboard: uc.clipboard, | |
| 857 | - | FaviconService: uc.favicon, | |
| 872 | + | Engine: engine, | |
| 873 | + | FilterManager: filterManager, | |
| 874 | + | HistoryRepo: repos.history, | |
| 875 | + | FavoriteRepo: repos.favorite, | |
| 876 | + | ZoomRepo: repos.zoom, | |
| 877 | + | PermissionRepo: repos.permission, | |
| 878 | + | TabsUC: uc.tabs, | |
| 879 | + | PanesUC: uc.panes, | |
| 880 | + | HistoryUC: uc.history, | |
| 881 | + | FavoritesUC: uc.favorites, | |
| 882 | + | ZoomUC: uc.zoom, | |
| 883 | + | PermissionUC: uc.permission, | |
| 884 | + | NavigateUC: uc.navigate, | |
| 885 | + | CopyURLUC: uc.copyURL, | |
| 886 | + | Clipboard: uc.clipboard, | |
| 887 | + | FaviconService: legacyFaviconService(uc), | |
| 888 | + | FaviconResolver: faviconResolver(uc), | |
| 889 | + | SystemviewFaviconResolver: faviconResolver(uc), | |
| 858 | 890 | FaviconAdapterConfig: adapter.FaviconAdapterConfig{ | |
| 859 | - | IsInternalURL: favicon.IsInternalURL, | |
| 860 | - | InternalDomain: favicon.InternalDomain, | |
| 861 | - | NormalizedIconSize: favicon.NormalizedIconSize, | |
| 862 | - | GetLogoBytes: favicon.GetLogoBytes, | |
| 891 | + | IsInternalURL: infrafavicon.IsInternalURL, | |
| 892 | + | InternalDomain: infrafavicon.InternalDomain, | |
| 893 | + | NormalizedIconSize: infrafavicon.NormalizedIconSize, | |
| 894 | + | GetLogoBytes: infrafavicon.GetLogoBytes, | |
| 863 | 895 | }, | |
| 864 | 896 | IdleInhibitor: idleInhibitor, | |
| 865 | 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 | 91 | func TestIsCEFSubprocess(t *testing.T) { | |
| 77 | 92 | tests := []struct { | |
| 78 | 93 | name string | |
M go.mod +1 -1
| @@ -23,6 +23,7 @@ require ( | |||
| 23 | 23 | github.com/spf13/viper v1.21.0 | |
| 24 | 24 | github.com/stretchr/testify v1.11.1 | |
| 25 | 25 | golang.org/x/image v0.41.0 | |
| 26 | + | golang.org/x/net v0.55.0 | |
| 26 | 27 | golang.org/x/sys v0.45.0 | |
| 27 | 28 | gopkg.in/yaml.v3 v3.0.1 | |
| 28 | 29 | ) | |
| @@ -74,7 +75,6 @@ require ( | |||
| 74 | 75 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 75 | 76 | golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect | |
| 76 | 77 | golang.org/x/mod v0.36.0 // indirect | |
| 77 | - | golang.org/x/net v0.54.0 // indirect | |
| 78 | 78 | golang.org/x/sync v0.20.0 // indirect | |
| 79 | 79 | golang.org/x/text v0.37.0 // indirect | |
| 80 | 80 | 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 | 18 | EnsureDiskCache(ctx context.Context, domain string) | |
| 19 | 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 | 101 | // OnFaviconChanged is called when the page favicon changes. | |
| 102 | 102 | // The parameter is a *gdk.Texture (passed as Texture interface to avoid GTK import in port layer). | |
| 103 | 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 | 106 | // OnClose is called when the WebView requests to close. | |
| 105 | 107 | OnClose func() | |
| 106 | 108 | // OnCreate is called when a popup window is requested. | |
A internal/application/usecase/favicon.go +401 -0
| @@ -0,0 +1,401 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | appport "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/favicon" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | var ErrFaviconMiss = appport.ErrFaviconMiss | |
| 15 | + | ||
| 16 | + | type ResolvePurpose = appport.FaviconResolvePurpose | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | ResolvePurposeUI = appport.FaviconResolvePurposeUI | |
| 20 | + | ResolvePurposeSystemview = appport.FaviconResolvePurposeSystemview | |
| 21 | + | ResolvePurposeRefresh = appport.FaviconResolvePurposeRefresh | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | type ResolveOptions = appport.FaviconResolveOptions | |
| 25 | + | type ResolvedFavicon = appport.ResolvedFavicon | |
| 26 | + | ||
| 27 | + | // SystemviewIconSize is the fixed PNG size served to internal systemview pages. | |
| 28 | + | const SystemviewIconSize = 32 | |
| 29 | + | ||
| 30 | + | type faviconInvalidatorRegistry struct { | |
| 31 | + | mu sync.RWMutex | |
| 32 | + | fallback appport.FaviconInvalidators | |
| 33 | + | invalidators []appport.FaviconInvalidator | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func (r *faviconInvalidatorRegistry) Register(invalidator appport.FaviconInvalidator) { | |
| 37 | + | if r == nil || invalidator == nil { | |
| 38 | + | return | |
| 39 | + | } | |
| 40 | + | r.mu.Lock() | |
| 41 | + | defer r.mu.Unlock() | |
| 42 | + | r.invalidators = append(r.invalidators, invalidator) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (r *faviconInvalidatorRegistry) InvalidateAll(ctx context.Context, key favicon.Key) error { | |
| 46 | + | if r == nil { | |
| 47 | + | return nil | |
| 48 | + | } | |
| 49 | + | r.mu.RLock() | |
| 50 | + | fallback := r.fallback | |
| 51 | + | invalidators := append([]appport.FaviconInvalidator(nil), r.invalidators...) | |
| 52 | + | r.mu.RUnlock() | |
| 53 | + | // The fallback invalidator owns pre-registered invalidation sets and is | |
| 54 | + | // treated as fatal; dynamic invalidators below are best-effort and joined. | |
| 55 | + | if fallback != nil { | |
| 56 | + | if err := fallback.InvalidateAll(ctx, key); err != nil { | |
| 57 | + | return err | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | var errs []error | |
| 61 | + | for _, invalidator := range invalidators { | |
| 62 | + | if err := invalidator.Invalidate(ctx, key); err != nil { | |
| 63 | + | errs = append(errs, err) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | return errors.Join(errs...) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | type FaviconUseCase struct { | |
| 70 | + | repo appport.FaviconRepository | |
| 71 | + | blobs appport.FaviconBlobStore | |
| 72 | + | fetcher appport.FaviconFetcher | |
| 73 | + | discoverer appport.FaviconDiscoverer | |
| 74 | + | converter appport.FaviconImageConverter | |
| 75 | + | scheduler appport.FaviconRefreshScheduler | |
| 76 | + | invalidators *faviconInvalidatorRegistry | |
| 77 | + | now func() time.Time | |
| 78 | + | ttl time.Duration | |
| 79 | + | background context.Context | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | type FaviconDeps struct { | |
| 83 | + | Repository appport.FaviconRepository | |
| 84 | + | BlobStore appport.FaviconBlobStore | |
| 85 | + | Fetcher appport.FaviconFetcher | |
| 86 | + | Discoverer appport.FaviconDiscoverer | |
| 87 | + | Converter appport.FaviconImageConverter | |
| 88 | + | Scheduler appport.FaviconRefreshScheduler | |
| 89 | + | Invalidators appport.FaviconInvalidators | |
| 90 | + | Now func() time.Time | |
| 91 | + | TTL time.Duration | |
| 92 | + | Background context.Context | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func NewFaviconUseCase(deps FaviconDeps) *FaviconUseCase { | |
| 96 | + | if deps.Now == nil { | |
| 97 | + | deps.Now = time.Now | |
| 98 | + | } | |
| 99 | + | if deps.TTL == 0 { | |
| 100 | + | deps.TTL = favicon.DefaultTTL | |
| 101 | + | } | |
| 102 | + | if deps.Background == nil { | |
| 103 | + | deps.Background = context.Background() | |
| 104 | + | } | |
| 105 | + | registry := &faviconInvalidatorRegistry{} | |
| 106 | + | if deps.Invalidators != nil { | |
| 107 | + | registry.fallback = deps.Invalidators | |
| 108 | + | } | |
| 109 | + | return &FaviconUseCase{ | |
| 110 | + | repo: deps.Repository, | |
| 111 | + | blobs: deps.BlobStore, | |
| 112 | + | fetcher: deps.Fetcher, | |
| 113 | + | discoverer: deps.Discoverer, | |
| 114 | + | converter: deps.Converter, | |
| 115 | + | scheduler: deps.Scheduler, | |
| 116 | + | invalidators: registry, | |
| 117 | + | now: deps.Now, | |
| 118 | + | ttl: deps.TTL, | |
| 119 | + | background: deps.Background, | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func (uc *FaviconUseCase) RegisterFaviconInvalidator(invalidator appport.FaviconInvalidator) { | |
| 124 | + | if uc == nil || uc.invalidators == nil || invalidator == nil { | |
| 125 | + | return | |
| 126 | + | } | |
| 127 | + | uc.invalidators.Register(invalidator) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (uc *FaviconUseCase) Resolve( | |
| 131 | + | ctx context.Context, | |
| 132 | + | rawURLOrDomain string, | |
| 133 | + | size int, | |
| 134 | + | options ResolveOptions, | |
| 135 | + | ) (*appport.ResolvedFavicon, error) { | |
| 136 | + | keys := favicon.Candidates(rawURLOrDomain) | |
| 137 | + | if len(keys) == 0 { | |
| 138 | + | return nil, ErrFaviconMiss | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | for _, key := range keys { | |
| 142 | + | if err := ctx.Err(); err != nil { | |
| 143 | + | return nil, err | |
| 144 | + | } | |
| 145 | + | meta, err := uc.repo.Get(ctx, key) | |
| 146 | + | if err != nil { | |
| 147 | + | return nil, err | |
| 148 | + | } | |
| 149 | + | if meta == nil { | |
| 150 | + | continue | |
| 151 | + | } | |
| 152 | + | bytes, contentType, err := uc.readForSize(ctx, key, size) | |
| 153 | + | if err != nil { | |
| 154 | + | if !errors.Is(err, ErrFaviconMiss) { | |
| 155 | + | return nil, err | |
| 156 | + | } | |
| 157 | + | continue | |
| 158 | + | } | |
| 159 | + | res := &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: meta} | |
| 160 | + | if favicon.ShouldRefresh(meta, uc.now(), uc.ttl) && options.ScheduleBackgroundRefresh { | |
| 161 | + | res.BackgroundRefreshScheduled = uc.scheduleRefresh(key, meta.PageURL) | |
| 162 | + | } | |
| 163 | + | return res, nil | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | if options.AllowBlockingRefresh { | |
| 167 | + | if err := uc.refresh(ctx, rawURLOrDomain, true); err != nil { | |
| 168 | + | return nil, err | |
| 169 | + | } | |
| 170 | + | return uc.Resolve(ctx, rawURLOrDomain, size, ResolveOptions{Purpose: options.Purpose}) | |
| 171 | + | } | |
| 172 | + | if options.ScheduleBackgroundRefresh { | |
| 173 | + | uc.scheduleRefresh(keys[0], rawURLOrDomain) | |
| 174 | + | } | |
| 175 | + | return nil, ErrFaviconMiss | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (uc *FaviconUseCase) ResolveSystemviewIcon(ctx context.Context, rawDomain string, size int) (*appport.ResolvedFavicon, error) { | |
| 179 | + | if size != SystemviewIconSize { | |
| 180 | + | return nil, ErrFaviconMiss | |
| 181 | + | } | |
| 182 | + | return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview}) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (uc *FaviconUseCase) Observe( | |
| 186 | + | ctx context.Context, | |
| 187 | + | pageURL, iconURL string, | |
| 188 | + | bytes []byte, | |
| 189 | + | source favicon.Source, | |
| 190 | + | contentType string, | |
| 191 | + | ) (*favicon.Metadata, error) { | |
| 192 | + | key, ok := favicon.CanonicalKey(pageURL) | |
| 193 | + | if !ok || len(bytes) == 0 { | |
| 194 | + | return nil, ErrFaviconMiss | |
| 195 | + | } | |
| 196 | + | old, err := uc.repo.Get(ctx, key) | |
| 197 | + | if err != nil { | |
| 198 | + | return nil, err | |
| 199 | + | } | |
| 200 | + | now := uc.now() | |
| 201 | + | if !favicon.HasContentChanged(old, bytes) { | |
| 202 | + | if err := uc.repo.UpdateLastChecked(ctx, key, old.ContentHash, now); err != nil { | |
| 203 | + | return nil, err | |
| 204 | + | } | |
| 205 | + | old.LastCheckedAt = now | |
| 206 | + | return old, nil | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | var converted *appport.ConvertedFavicon | |
| 210 | + | if uc.converter != nil { | |
| 211 | + | var err error | |
| 212 | + | converted, err = uc.converter.Convert(ctx, bytes, contentType, []int{32}) | |
| 213 | + | if err != nil { | |
| 214 | + | return nil, err | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | if err := uc.blobs.WriteOriginal(ctx, key, bytes, contentType); err != nil { | |
| 219 | + | return nil, err | |
| 220 | + | } | |
| 221 | + | if err := uc.blobs.RemoveDerived(ctx, key); err != nil { | |
| 222 | + | return nil, err | |
| 223 | + | } | |
| 224 | + | if converted != nil { | |
| 225 | + | if len(converted.PNG) > 0 { | |
| 226 | + | if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil { | |
| 227 | + | return nil, err | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | if b := converted.SizedPNG[32]; len(b) > 0 { | |
| 231 | + | if err := uc.blobs.WriteSizedPNG(ctx, key, 32, b); err != nil { | |
| 232 | + | return nil, err | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | meta := favicon.Metadata{ | |
| 237 | + | Key: key, | |
| 238 | + | SourceURL: iconURL, | |
| 239 | + | PageURL: pageURL, | |
| 240 | + | Source: source, | |
| 241 | + | ContentHash: favicon.Hash(bytes), | |
| 242 | + | ContentType: contentType, | |
| 243 | + | UpdatedAt: now, | |
| 244 | + | LastCheckedAt: now, | |
| 245 | + | } | |
| 246 | + | if err := uc.repo.Upsert(ctx, meta); err != nil { | |
| 247 | + | return nil, err | |
| 248 | + | } | |
| 249 | + | if err := uc.invalidateCaches(ctx, key); err != nil { | |
| 250 | + | return nil, err | |
| 251 | + | } | |
| 252 | + | return &meta, nil | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func (uc *FaviconUseCase) RefreshIfStale(ctx context.Context, pageURL string) error { | |
| 256 | + | return uc.refresh(ctx, pageURL, false) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func (uc *FaviconUseCase) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string) error { | |
| 260 | + | if uc.fetcher == nil { | |
| 261 | + | return ErrFaviconMiss | |
| 262 | + | } | |
| 263 | + | var lastMiss error | |
| 264 | + | for _, iconURL := range iconURLs { | |
| 265 | + | if err := ctx.Err(); err != nil { | |
| 266 | + | return err | |
| 267 | + | } | |
| 268 | + | fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL, IconURL: iconURL}) | |
| 269 | + | if errors.Is(err, ErrFaviconMiss) { | |
| 270 | + | lastMiss = err | |
| 271 | + | continue | |
| 272 | + | } | |
| 273 | + | if err != nil { | |
| 274 | + | return err | |
| 275 | + | } | |
| 276 | + | _, err = uc.Observe(ctx, fetched.PageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType) | |
| 277 | + | if errors.Is(err, ErrFaviconMiss) { | |
| 278 | + | lastMiss = err | |
| 279 | + | continue | |
| 280 | + | } | |
| 281 | + | return err | |
| 282 | + | } | |
| 283 | + | if lastMiss != nil { | |
| 284 | + | return lastMiss | |
| 285 | + | } | |
| 286 | + | return uc.RefreshIfStale(ctx, pageURL) | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func (uc *FaviconUseCase) refresh(ctx context.Context, pageURL string, force bool) error { | |
| 290 | + | keys := favicon.Candidates(pageURL) | |
| 291 | + | if len(keys) == 0 { | |
| 292 | + | return ErrFaviconMiss | |
| 293 | + | } | |
| 294 | + | if !force { | |
| 295 | + | fresh, err := uc.anyCandidateFresh(ctx, keys) | |
| 296 | + | if err != nil { | |
| 297 | + | return err | |
| 298 | + | } | |
| 299 | + | if fresh { | |
| 300 | + | return nil | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | return uc.fetchAndObserve(ctx, pageURL) | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func (uc *FaviconUseCase) refreshKey(ctx context.Context, key favicon.Key, pageURL string) error { | |
| 307 | + | meta, err := uc.repo.Get(ctx, key) | |
| 308 | + | if err != nil { | |
| 309 | + | return err | |
| 310 | + | } | |
| 311 | + | if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) { | |
| 312 | + | return nil | |
| 313 | + | } | |
| 314 | + | return uc.fetchAndObserve(ctx, pageURL) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func (uc *FaviconUseCase) anyCandidateFresh(ctx context.Context, keys []favicon.Key) (bool, error) { | |
| 318 | + | for _, key := range keys { | |
| 319 | + | meta, err := uc.repo.Get(ctx, key) | |
| 320 | + | if err != nil { | |
| 321 | + | return false, err | |
| 322 | + | } | |
| 323 | + | if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) { | |
| 324 | + | return true, nil | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | return false, nil | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func (uc *FaviconUseCase) fetchAndObserve(ctx context.Context, pageURL string) error { | |
| 331 | + | if uc.fetcher == nil { | |
| 332 | + | return ErrFaviconMiss | |
| 333 | + | } | |
| 334 | + | fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL}) | |
| 335 | + | if err != nil { | |
| 336 | + | return err | |
| 337 | + | } | |
| 338 | + | _, err = uc.Observe(ctx, fetched.PageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType) | |
| 339 | + | return err | |
| 340 | + | } | |
| 341 | + | ||
| 342 | + | func (uc *FaviconUseCase) EnsureSized(ctx context.Context, key favicon.Key, size int) error { | |
| 343 | + | if _, _, err := uc.blobs.ReadSizedPNG(ctx, key, size); err == nil { | |
| 344 | + | return nil | |
| 345 | + | } else if !errors.Is(err, ErrFaviconMiss) { | |
| 346 | + | return err | |
| 347 | + | } | |
| 348 | + | orig, ct, err := uc.blobs.ReadOriginal(ctx, key) | |
| 349 | + | if err != nil { | |
| 350 | + | return err | |
| 351 | + | } | |
| 352 | + | converted, err := uc.converter.Convert(ctx, orig, ct, []int{size}) | |
| 353 | + | if err != nil { | |
| 354 | + | return err | |
| 355 | + | } | |
| 356 | + | if len(converted.PNG) > 0 { | |
| 357 | + | if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil { | |
| 358 | + | return err | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | if b := converted.SizedPNG[size]; len(b) > 0 { | |
| 362 | + | return uc.blobs.WriteSizedPNG(ctx, key, size, b) | |
| 363 | + | } | |
| 364 | + | return ErrFaviconMiss | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func (uc *FaviconUseCase) Invalidate(ctx context.Context, key favicon.Key) error { | |
| 368 | + | if uc.blobs != nil { | |
| 369 | + | if err := uc.blobs.RemoveDerived(ctx, key); err != nil { | |
| 370 | + | return err | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | return uc.invalidateCaches(ctx, key) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func (uc *FaviconUseCase) invalidateCaches(ctx context.Context, key favicon.Key) error { | |
| 377 | + | if uc.invalidators != nil { | |
| 378 | + | return uc.invalidators.InvalidateAll(ctx, key) | |
| 379 | + | } | |
| 380 | + | return nil | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | func (uc *FaviconUseCase) readForSize(ctx context.Context, key favicon.Key, size int) ([]byte, string, error) { | |
| 384 | + | if size < 0 { | |
| 385 | + | return nil, "", fmt.Errorf("invalid favicon size: %d", size) | |
| 386 | + | } | |
| 387 | + | if size > 0 { | |
| 388 | + | if err := uc.EnsureSized(ctx, key, size); err != nil { | |
| 389 | + | return nil, "", err | |
| 390 | + | } | |
| 391 | + | return uc.blobs.ReadSizedPNG(ctx, key, size) | |
| 392 | + | } | |
| 393 | + | return uc.blobs.ReadPNG(ctx, key) | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | func (uc *FaviconUseCase) scheduleRefresh(key favicon.Key, pageURL string) bool { | |
| 397 | + | if uc.scheduler == nil { | |
| 398 | + | return false | |
| 399 | + | } | |
| 400 | + | return uc.scheduler.Schedule(uc.background, key, func(ctx context.Context) { _ = uc.refreshKey(ctx, key, pageURL) }) | |
| 401 | + | } | |
A internal/application/usecase/favicon_test.go +499 -0
| @@ -0,0 +1,499 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | appport "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/favicon" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestFaviconResolveExactHit(t *testing.T) { | |
| 14 | + | fx := newFaviconFixture() | |
| 15 | + | fx.seed("docs.example.com", []byte("png32"), false) | |
| 16 | + | got, err := fx.uc.Resolve(context.Background(), "https://docs.example.com/a", 32, ResolveOptions{Purpose: ResolvePurposeUI}) | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | if got.Key != "docs.example.com" || string(got.Bytes) != "png32" { | |
| 21 | + | t.Fatalf("unexpected resolve: %#v %q", got, got.Bytes) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestFaviconResolveParentFallback(t *testing.T) { | |
| 26 | + | fx := newFaviconFixture() | |
| 27 | + | fx.seed("example.com", []byte("parent"), false) | |
| 28 | + | got, err := fx.uc.Resolve(context.Background(), "https://docs.example.com/a", 32, ResolveOptions{Purpose: ResolvePurposeUI}) | |
| 29 | + | if err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | if got.Key != "example.com" || string(got.Bytes) != "parent" { | |
| 33 | + | t.Fatalf("unexpected fallback: %#v", got) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) { | |
| 38 | + | fx := newFaviconFixture() | |
| 39 | + | fx.seed("example.com", []byte("old"), true) | |
| 40 | + | for i := 0; i < 2; i++ { | |
| 41 | + | got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{ScheduleBackgroundRefresh: true}) | |
| 42 | + | if err != nil { | |
| 43 | + | t.Fatal(err) | |
| 44 | + | } | |
| 45 | + | if !got.BackgroundRefreshScheduled && i == 0 { | |
| 46 | + | t.Fatal("first stale resolve did not schedule refresh") | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | if fx.scheduler.schedules != 1 { | |
| 50 | + | t.Fatalf("schedules=%d", fx.scheduler.schedules) | |
| 51 | + | } | |
| 52 | + | if fx.scheduler.usedCallerContext { | |
| 53 | + | t.Fatal("scheduler used caller context") | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestFaviconResolveStaleExactRefreshIgnoresFreshParent(t *testing.T) { | |
| 58 | + | fx := newFaviconFixture() | |
| 59 | + | fx.seed("docs.example.com", []byte("old-exact"), true) | |
| 60 | + | fx.seed("example.com", []byte("fresh-parent"), false) | |
| 61 | + | got, err := fx.uc.Resolve( | |
| 62 | + | context.Background(), | |
| 63 | + | "https://docs.example.com/page", | |
| 64 | + | 32, | |
| 65 | + | ResolveOptions{ScheduleBackgroundRefresh: true}, | |
| 66 | + | ) | |
| 67 | + | if err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | if got.Key != "docs.example.com" || !got.BackgroundRefreshScheduled { | |
| 71 | + | t.Fatalf("unexpected resolve: %#v", got) | |
| 72 | + | } | |
| 73 | + | fx.scheduler.run("docs.example.com") | |
| 74 | + | if string(fx.blobs.original["docs.example.com"]) != "fetched" { | |
| 75 | + | t.Fatalf("exact key was not refreshed, originals=%v", fx.blobs.original) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestFaviconObserveSameHashUpdatesMetadataOnly(t *testing.T) { | |
| 80 | + | fx := newFaviconFixture() | |
| 81 | + | fx.seedOriginal([]byte("same")) | |
| 82 | + | if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://cdn.test/icon.png", []byte("same"), favicon.SourceEngine, "image/png"); err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | if fx.blobs.writeOriginals != 0 || fx.converter.calls != 0 || fx.invalidators.calls != 0 { | |
| 86 | + | t.Fatalf("rewrote/converted/invalidated same hash") | |
| 87 | + | } | |
| 88 | + | if fx.repo.byKey["example.com"].LastCheckedAt != fx.now { | |
| 89 | + | t.Fatal("last checked not updated") | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestFaviconObserveChangedHashRegeneratesAndInvalidates(t *testing.T) { | |
| 94 | + | fx := newFaviconFixture() | |
| 95 | + | fx.seedOriginal([]byte("old")) | |
| 96 | + | if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://cdn.test/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); err != nil { | |
| 97 | + | t.Fatal(err) | |
| 98 | + | } | |
| 99 | + | if fx.blobs.writeOriginals != 1 || fx.converter.calls != 1 || fx.blobs.removeDerived != 1 || fx.invalidators.calls != 1 { | |
| 100 | + | t.Fatalf("writes=%d converts=%d remove=%d invalidates=%d", fx.blobs.writeOriginals, fx.converter.calls, fx.blobs.removeDerived, fx.invalidators.calls) | |
| 101 | + | } | |
| 102 | + | if string(fx.blobs.png["example.com"]) != "png:new" || string(fx.blobs.sized["example.com"][32]) != "png32:new" { | |
| 103 | + | t.Fatal("derived pngs not regenerated") | |
| 104 | + | } | |
| 105 | + | if fx.repo.byKey["example.com"].SourceURL != "https://cdn.test/icon.png" { | |
| 106 | + | t.Fatal("iconURL not stored as metadata") | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func TestFaviconObserveWithoutConverterStoresOriginalOnly(t *testing.T) { | |
| 111 | + | fx := newFaviconFixture() | |
| 112 | + | fx.uc.converter = nil | |
| 113 | + | if _, err := fx.uc.Observe(context.Background(), "https://example.com/page", "https://example.com/icon.png", []byte("raw"), favicon.SourceEngine, "image/png"); err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | if string(fx.blobs.original["example.com"]) != "raw" { | |
| 117 | + | t.Fatalf("original not stored: %v", fx.blobs.original) | |
| 118 | + | } | |
| 119 | + | if len(fx.blobs.png) != 0 || len(fx.blobs.sized) != 0 { | |
| 120 | + | t.Fatalf("unexpected derivatives: png=%v sized=%v", fx.blobs.png, fx.blobs.sized) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestFaviconResolveMissingFetchesOnlyWhenBlockingAllowed(t *testing.T) { | |
| 125 | + | fx := newFaviconFixture() | |
| 126 | + | if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, ErrFaviconMiss) { | |
| 127 | + | t.Fatalf("default resolve err=%v", err) | |
| 128 | + | } | |
| 129 | + | if fx.fetcher.calls != 0 { | |
| 130 | + | t.Fatal("default resolve blocked on fetch") | |
| 131 | + | } | |
| 132 | + | got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true}) | |
| 133 | + | if err != nil { | |
| 134 | + | t.Fatal(err) | |
| 135 | + | } | |
| 136 | + | if got.Key != "example.com" || fx.fetcher.calls != 1 { | |
| 137 | + | t.Fatalf("blocking resolve failed: %#v calls=%d", got, fx.fetcher.calls) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func TestFaviconRefreshIfStaleUsesParentFallbackBeforeFetching(t *testing.T) { | |
| 142 | + | fx := newFaviconFixture() | |
| 143 | + | fx.seed("example.com", []byte("parent"), false) | |
| 144 | + | if err := fx.uc.RefreshIfStale(context.Background(), "https://docs.example.com/page"); err != nil { | |
| 145 | + | t.Fatal(err) | |
| 146 | + | } | |
| 147 | + | if fx.fetcher.calls != 0 { | |
| 148 | + | t.Fatalf("fresh parent fallback should not fetch, calls=%d", fx.fetcher.calls) | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestFaviconUseCasePropagatesRepositoryErrors(t *testing.T) { | |
| 153 | + | fx := newFaviconFixture() | |
| 154 | + | fx.repo.err = errors.New("database unavailable") | |
| 155 | + | if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, fx.repo.err) { | |
| 156 | + | t.Fatalf("Resolve err=%v, want %v", err, fx.repo.err) | |
| 157 | + | } | |
| 158 | + | if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://cdn.example/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); !errors.Is(err, fx.repo.err) { | |
| 159 | + | t.Fatalf("Observe err=%v, want %v", err, fx.repo.err) | |
| 160 | + | } | |
| 161 | + | if err := fx.uc.RefreshIfStale(context.Background(), "https://example.com"); !errors.Is(err, fx.repo.err) { | |
| 162 | + | t.Fatalf("RefreshIfStale err=%v, want %v", err, fx.repo.err) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestFaviconResolvePropagatesBlobErrors(t *testing.T) { | |
| 167 | + | fx := newFaviconFixture() | |
| 168 | + | fx.seed("example.com", []byte("cached"), false) | |
| 169 | + | fx.blobs.err = errors.New("disk unavailable") | |
| 170 | + | if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, fx.blobs.err) { | |
| 171 | + | t.Fatalf("Resolve err=%v, want %v", err, fx.blobs.err) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestFaviconResolveBlockingRefreshesWhenMetadataFreshButBlobMissing(t *testing.T) { | |
| 176 | + | fx := newFaviconFixture() | |
| 177 | + | fx.repo.byKey["example.com"] = &favicon.Metadata{Key: "example.com", PageURL: "https://example.com", ContentHash: favicon.Hash([]byte("old")), LastCheckedAt: fx.now} | |
| 178 | + | got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true}) | |
| 179 | + | if err != nil { | |
| 180 | + | t.Fatal(err) | |
| 181 | + | } | |
| 182 | + | if got.Key != "example.com" || fx.fetcher.calls != 1 || string(got.Bytes) != "png32:fetched" { | |
| 183 | + | t.Fatalf("unexpected resolve after forced refresh: got=%#v calls=%d bytes=%q", got, fx.fetcher.calls, got.Bytes) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func TestFaviconResolveBlockingPropagatesFetchErrors(t *testing.T) { | |
| 188 | + | fx := newFaviconFixture() | |
| 189 | + | fx.fetcher.err = errors.New("network unavailable") | |
| 190 | + | if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true}); !errors.Is(err, fx.fetcher.err) { | |
| 191 | + | t.Fatalf("Resolve err=%v, want %v", err, fx.fetcher.err) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func TestFaviconRefreshFromIconURLsUsesPageURLKey(t *testing.T) { | |
| 196 | + | fx := newFaviconFixture() | |
| 197 | + | if err := fx.uc.RefreshFromIconURLs(context.Background(), "https://docs.example.com/page", []string{"https://cdn.example.net/icon.png"}); err != nil { | |
| 198 | + | t.Fatal(err) | |
| 199 | + | } | |
| 200 | + | if _, ok := fx.repo.byKey["docs.example.com"]; !ok { | |
| 201 | + | t.Fatalf("page URL key was not stored; keys=%v", fx.repo.byKey) | |
| 202 | + | } | |
| 203 | + | if _, ok := fx.repo.byKey["cdn.example.net"]; ok { | |
| 204 | + | t.Fatal("icon URL host should not be used as cache key") | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestFaviconObserveConversionErrorDoesNotMutateExistingAssets(t *testing.T) { | |
| 209 | + | fx := newFaviconFixture() | |
| 210 | + | fx.seedOriginal([]byte("old")) | |
| 211 | + | fx.blobs.png["example.com"] = []byte("old-png") | |
| 212 | + | fx.blobs.sized["example.com"] = map[int][]byte{32: []byte("old-32")} | |
| 213 | + | fx.converter.err = errors.New("unsupported image") | |
| 214 | + | ||
| 215 | + | if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://example.com/favicon.ico", []byte("bad"), favicon.SourceEngine, "image/svg+xml"); !errors.Is(err, fx.converter.err) { | |
| 216 | + | t.Fatalf("Observe err=%v, want %v", err, fx.converter.err) | |
| 217 | + | } | |
| 218 | + | if string(fx.blobs.original["example.com"]) != "old" || string(fx.blobs.png["example.com"]) != "old-png" || string(fx.blobs.sized["example.com"][32]) != "old-32" { | |
| 219 | + | t.Fatalf("existing assets mutated on conversion error") | |
| 220 | + | } | |
| 221 | + | if fx.blobs.writeOriginals != 0 || fx.blobs.removeDerived != 0 || fx.invalidators.calls != 0 { | |
| 222 | + | t.Fatalf("unexpected mutation counters writes=%d remove=%d invalidates=%d", fx.blobs.writeOriginals, fx.blobs.removeDerived, fx.invalidators.calls) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) { | |
| 227 | + | fx := newFaviconFixture() | |
| 228 | + | fx.seedOriginal([]byte("old")) | |
| 229 | + | fx.blobs.readSizedErr = errors.New("sized store unavailable") | |
| 230 | + | if err := fx.uc.EnsureSized(context.Background(), "example.com", 32); !errors.Is(err, fx.blobs.readSizedErr) { | |
| 231 | + | t.Fatalf("EnsureSized err=%v, want %v", err, fx.blobs.readSizedErr) | |
| 232 | + | } | |
| 233 | + | if fx.converter.calls != 0 { | |
| 234 | + | t.Fatal("converter called after non-miss ReadSizedPNG error") | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | type faviconFixture struct { | |
| 239 | + | repo *fakeFaviconRepo | |
| 240 | + | blobs *fakeBlobStore | |
| 241 | + | converter *fakeConverter | |
| 242 | + | scheduler *fakeScheduler | |
| 243 | + | invalidators *fakeInvalidators | |
| 244 | + | fetcher *fakeFetcher | |
| 245 | + | uc *FaviconUseCase | |
| 246 | + | now time.Time | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | type faviconTestContextKey string | |
| 250 | + | ||
| 251 | + | const faviconFixtureBackgroundKey faviconTestContextKey = "bg" | |
| 252 | + | ||
| 253 | + | func newFaviconFixture() *faviconFixture { | |
| 254 | + | now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) | |
| 255 | + | fx := &faviconFixture{ | |
| 256 | + | repo: newFakeRepo(), | |
| 257 | + | blobs: newFakeBlobStore(), | |
| 258 | + | converter: &fakeConverter{}, | |
| 259 | + | scheduler: &fakeScheduler{seen: map[favicon.Key]bool{}}, | |
| 260 | + | invalidators: &fakeInvalidators{}, | |
| 261 | + | fetcher: &fakeFetcher{}, | |
| 262 | + | now: now, | |
| 263 | + | } | |
| 264 | + | fx.uc = NewFaviconUseCase(FaviconDeps{ | |
| 265 | + | Repository: fx.repo, | |
| 266 | + | BlobStore: fx.blobs, | |
| 267 | + | Converter: fx.converter, | |
| 268 | + | Scheduler: fx.scheduler, | |
| 269 | + | Invalidators: fx.invalidators, | |
| 270 | + | Fetcher: fx.fetcher, | |
| 271 | + | Now: func() time.Time { return fx.now }, | |
| 272 | + | Background: context.WithValue(context.Background(), faviconFixtureBackgroundKey, true), | |
| 273 | + | }) | |
| 274 | + | return fx | |
| 275 | + | } | |
| 276 | + | func (f *faviconFixture) seed(key string, sized []byte, stale bool) { | |
| 277 | + | checked := f.now | |
| 278 | + | if stale { | |
| 279 | + | checked = f.now.Add(-favicon.DefaultTTL - time.Hour) | |
| 280 | + | } | |
| 281 | + | f.repo.byKey[favicon.Key(key)] = &favicon.Metadata{Key: favicon.Key(key), PageURL: "https://" + key, ContentHash: favicon.Hash([]byte("orig")), LastCheckedAt: checked} | |
| 282 | + | f.blobs.sized[favicon.Key(key)] = map[int][]byte{32: sized} | |
| 283 | + | } | |
| 284 | + | func (f *faviconFixture) seedOriginal(b []byte) { | |
| 285 | + | const contentType = "image/png" | |
| 286 | + | key := favicon.Key("example.com") | |
| 287 | + | f.repo.byKey[key] = &favicon.Metadata{ | |
| 288 | + | Key: key, | |
| 289 | + | PageURL: "https://example.com", | |
| 290 | + | ContentHash: favicon.Hash(b), | |
| 291 | + | ContentType: contentType, | |
| 292 | + | LastCheckedAt: f.now.Add(-time.Hour), | |
| 293 | + | } | |
| 294 | + | f.blobs.original[key] = b | |
| 295 | + | f.blobs.contentTypes[key] = contentType | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | type fakeFaviconRepo struct { | |
| 299 | + | byKey map[favicon.Key]*favicon.Metadata | |
| 300 | + | err error | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func newFakeRepo() *fakeFaviconRepo { | |
| 304 | + | return &fakeFaviconRepo{byKey: map[favicon.Key]*favicon.Metadata{}} | |
| 305 | + | } | |
| 306 | + | func (r *fakeFaviconRepo) Get(_ context.Context, key favicon.Key) (*favicon.Metadata, error) { | |
| 307 | + | if r.err != nil { | |
| 308 | + | return nil, r.err | |
| 309 | + | } | |
| 310 | + | if m := r.byKey[key]; m != nil { | |
| 311 | + | cp := *m | |
| 312 | + | return &cp, nil | |
| 313 | + | } | |
| 314 | + | return nil, nil | |
| 315 | + | } | |
| 316 | + | func (r *fakeFaviconRepo) FindFirst(ctx context.Context, keys []favicon.Key) (*favicon.Metadata, error) { | |
| 317 | + | if r.err != nil { | |
| 318 | + | return nil, r.err | |
| 319 | + | } | |
| 320 | + | for _, k := range keys { | |
| 321 | + | if m, _ := r.Get(ctx, k); m != nil { | |
| 322 | + | return m, nil | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | return nil, nil | |
| 326 | + | } | |
| 327 | + | func (r *fakeFaviconRepo) Upsert(_ context.Context, meta favicon.Metadata) error { | |
| 328 | + | if r.err != nil { | |
| 329 | + | return r.err | |
| 330 | + | } | |
| 331 | + | cp := meta | |
| 332 | + | r.byKey[meta.Key] = &cp | |
| 333 | + | return nil | |
| 334 | + | } | |
| 335 | + | func (r *fakeFaviconRepo) UpdateLastChecked(_ context.Context, key favicon.Key, hash string, checkedAt time.Time) error { | |
| 336 | + | if r.err != nil { | |
| 337 | + | return r.err | |
| 338 | + | } | |
| 339 | + | r.byKey[key].ContentHash = hash | |
| 340 | + | r.byKey[key].LastCheckedAt = checkedAt | |
| 341 | + | return nil | |
| 342 | + | } | |
| 343 | + | func (r *fakeFaviconRepo) Delete(_ context.Context, key favicon.Key) error { | |
| 344 | + | if r.err != nil { | |
| 345 | + | return r.err | |
| 346 | + | } | |
| 347 | + | delete(r.byKey, key) | |
| 348 | + | return nil | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | type fakeBlobStore struct { | |
| 352 | + | original, png map[favicon.Key][]byte | |
| 353 | + | sized map[favicon.Key]map[int][]byte | |
| 354 | + | contentTypes map[favicon.Key]string | |
| 355 | + | writeOriginals, removeDerived int | |
| 356 | + | err error | |
| 357 | + | readSizedErr error | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func newFakeBlobStore() *fakeBlobStore { | |
| 361 | + | return &fakeBlobStore{original: map[favicon.Key][]byte{}, png: map[favicon.Key][]byte{}, sized: map[favicon.Key]map[int][]byte{}, contentTypes: map[favicon.Key]string{}} | |
| 362 | + | } | |
| 363 | + | func (b *fakeBlobStore) ReadOriginal(_ context.Context, key favicon.Key) ([]byte, string, error) { | |
| 364 | + | if b.err != nil { | |
| 365 | + | return nil, "", b.err | |
| 366 | + | } | |
| 367 | + | v := b.original[key] | |
| 368 | + | if v == nil { | |
| 369 | + | return nil, "", ErrFaviconMiss | |
| 370 | + | } | |
| 371 | + | return v, b.contentTypes[key], nil | |
| 372 | + | } | |
| 373 | + | func (b *fakeBlobStore) WriteOriginal(_ context.Context, key favicon.Key, data []byte, ct string) error { | |
| 374 | + | if b.err != nil { | |
| 375 | + | return b.err | |
| 376 | + | } | |
| 377 | + | b.writeOriginals++ | |
| 378 | + | b.original[key] = data | |
| 379 | + | b.contentTypes[key] = ct | |
| 380 | + | return nil | |
| 381 | + | } | |
| 382 | + | func (b *fakeBlobStore) ReadPNG(_ context.Context, key favicon.Key) ([]byte, string, error) { | |
| 383 | + | if b.err != nil { | |
| 384 | + | return nil, "", b.err | |
| 385 | + | } | |
| 386 | + | v := b.png[key] | |
| 387 | + | if v == nil { | |
| 388 | + | return nil, "", ErrFaviconMiss | |
| 389 | + | } | |
| 390 | + | return v, "image/png", nil | |
| 391 | + | } | |
| 392 | + | func (b *fakeBlobStore) WritePNG(_ context.Context, key favicon.Key, data []byte) error { | |
| 393 | + | if b.err != nil { | |
| 394 | + | return b.err | |
| 395 | + | } | |
| 396 | + | b.png[key] = data | |
| 397 | + | return nil | |
| 398 | + | } | |
| 399 | + | func (b *fakeBlobStore) ReadSizedPNG(_ context.Context, key favicon.Key, size int) ([]byte, string, error) { | |
| 400 | + | if b.err != nil { | |
| 401 | + | return nil, "", b.err | |
| 402 | + | } | |
| 403 | + | if b.readSizedErr != nil { | |
| 404 | + | return nil, "", b.readSizedErr | |
| 405 | + | } | |
| 406 | + | if b.sized[key] == nil || b.sized[key][size] == nil { | |
| 407 | + | return nil, "", ErrFaviconMiss | |
| 408 | + | } | |
| 409 | + | return b.sized[key][size], "image/png", nil | |
| 410 | + | } | |
| 411 | + | func (b *fakeBlobStore) WriteSizedPNG(_ context.Context, key favicon.Key, size int, data []byte) error { | |
| 412 | + | if b.err != nil { | |
| 413 | + | return b.err | |
| 414 | + | } | |
| 415 | + | if b.sized[key] == nil { | |
| 416 | + | b.sized[key] = map[int][]byte{} | |
| 417 | + | } | |
| 418 | + | b.sized[key][size] = data | |
| 419 | + | return nil | |
| 420 | + | } | |
| 421 | + | func (b *fakeBlobStore) RemoveDerived(_ context.Context, key favicon.Key) error { | |
| 422 | + | if b.err != nil { | |
| 423 | + | return b.err | |
| 424 | + | } | |
| 425 | + | b.removeDerived++ | |
| 426 | + | delete(b.png, key) | |
| 427 | + | delete(b.sized, key) | |
| 428 | + | return nil | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | type fakeConverter struct { | |
| 432 | + | calls int | |
| 433 | + | err error | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | func (c *fakeConverter) Convert(_ context.Context, original []byte, _ string, sizes []int) (*appport.ConvertedFavicon, error) { | |
| 437 | + | c.calls++ | |
| 438 | + | if c.err != nil { | |
| 439 | + | return nil, c.err | |
| 440 | + | } | |
| 441 | + | out := &appport.ConvertedFavicon{PNG: append([]byte("png:"), original...), SizedPNG: map[int][]byte{}} | |
| 442 | + | for _, s := range sizes { | |
| 443 | + | out.SizedPNG[s] = append([]byte("png32:"), original...) | |
| 444 | + | } | |
| 445 | + | return out, nil | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | type fakeScheduler struct { | |
| 449 | + | seen map[favicon.Key]bool | |
| 450 | + | works map[favicon.Key]func(context.Context) | |
| 451 | + | schedules int | |
| 452 | + | usedCallerContext bool | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | func (s *fakeScheduler) Schedule(ctx context.Context, key favicon.Key, work func(context.Context)) bool { | |
| 456 | + | if ctx.Value(faviconFixtureBackgroundKey) != true { | |
| 457 | + | s.usedCallerContext = true | |
| 458 | + | } | |
| 459 | + | if s.seen[key] { | |
| 460 | + | return false | |
| 461 | + | } | |
| 462 | + | if s.works == nil { | |
| 463 | + | s.works = map[favicon.Key]func(context.Context){} | |
| 464 | + | } | |
| 465 | + | s.seen[key] = true | |
| 466 | + | s.works[key] = work | |
| 467 | + | s.schedules++ | |
| 468 | + | return true | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | func (s *fakeScheduler) run(key favicon.Key) { | |
| 472 | + | if work := s.works[key]; work != nil { | |
| 473 | + | work(context.Background()) | |
| 474 | + | } | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | type fakeInvalidators struct{ calls int } | |
| 478 | + | ||
| 479 | + | func (i *fakeInvalidators) InvalidateAll(_ context.Context, _ favicon.Key) error { | |
| 480 | + | i.calls++ | |
| 481 | + | return nil | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | type fakeFetcher struct { | |
| 485 | + | calls int | |
| 486 | + | err error | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | func (f *fakeFetcher) Fetch(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) { | |
| 490 | + | f.calls++ | |
| 491 | + | if f.err != nil { | |
| 492 | + | return nil, f.err | |
| 493 | + | } | |
| 494 | + | iconURL := req.IconURL | |
| 495 | + | if iconURL == "" { | |
| 496 | + | iconURL = "https://example.com/favicon.ico" | |
| 497 | + | } | |
| 498 | + | return &appport.FaviconFetchedIcon{PageURL: req.PageURL, IconURL: iconURL, Bytes: []byte("fetched"), Source: favicon.SourceDuckDuckGo, ContentType: "image/png"}, nil | |
| 499 | + | } | |
A internal/domain/favicon/key.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | package favicon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net" | |
| 5 | + | "net/url" | |
| 6 | + | "slices" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "golang.org/x/net/idna" | |
| 10 | + | "golang.org/x/net/publicsuffix" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type Key string | |
| 14 | + | ||
| 15 | + | func CanonicalKey(raw string) (Key, bool) { | |
| 16 | + | candidates := Candidates(raw) | |
| 17 | + | if len(candidates) == 0 { | |
| 18 | + | return "", false | |
| 19 | + | } | |
| 20 | + | return candidates[0], true | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func Candidates(raw string) []Key { | |
| 24 | + | host, port, hasScheme, scheme, ok := parseHost(raw) | |
| 25 | + | if !ok { | |
| 26 | + | return nil | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | host = normalizeHost(host) | |
| 30 | + | if host == "" { | |
| 31 | + | return nil | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | if isExactHost(host) { | |
| 35 | + | if port != "" && (!hasScheme || !isDefaultPort(scheme, port)) { | |
| 36 | + | return []Key{Key(net.JoinHostPort(host, port))} | |
| 37 | + | } | |
| 38 | + | return []Key{Key(host)} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | ascii, err := idna.Lookup.ToASCII(host) | |
| 42 | + | if err != nil { | |
| 43 | + | return nil | |
| 44 | + | } | |
| 45 | + | host = strings.ToLower(strings.TrimSuffix(ascii, ".")) | |
| 46 | + | host = stripWWW(host) | |
| 47 | + | ||
| 48 | + | if port != "" { | |
| 49 | + | if !hasScheme || !isDefaultPort(scheme, port) { | |
| 50 | + | return []Key{Key(net.JoinHostPort(host, port))} | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | labels := strings.Split(host, ".") | |
| 55 | + | if slices.Contains(labels, "") { | |
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | registrable, err := publicsuffix.EffectiveTLDPlusOne(host) | |
| 60 | + | if err != nil { | |
| 61 | + | return []Key{Key(host)} | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | var out []Key | |
| 65 | + | for i := range len(labels) { | |
| 66 | + | candidate := strings.Join(labels[i:], ".") | |
| 67 | + | out = append(out, Key(candidate)) | |
| 68 | + | if candidate == registrable { | |
| 69 | + | break | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | return out | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func parseHost(raw string) (host, port string, hasScheme bool, scheme string, ok bool) { | |
| 76 | + | raw = strings.TrimSpace(raw) | |
| 77 | + | if raw == "" { | |
| 78 | + | return "", "", false, "", false | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | if strings.Contains(raw, "://") { | |
| 82 | + | u, err := url.Parse(raw) | |
| 83 | + | if err != nil || u.Scheme == "" { | |
| 84 | + | return "", "", false, "", false | |
| 85 | + | } | |
| 86 | + | scheme = strings.ToLower(u.Scheme) | |
| 87 | + | if scheme != "http" && scheme != "https" { | |
| 88 | + | return "", "", true, scheme, false | |
| 89 | + | } | |
| 90 | + | host = u.Hostname() | |
| 91 | + | port = u.Port() | |
| 92 | + | return host, port, true, scheme, host != "" | |
| 93 | + | } | |
| 94 | + | if strings.HasPrefix(strings.ToLower(raw), "about:") { | |
| 95 | + | return "", "", false, "", false | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | hostport := raw | |
| 99 | + | if cut := strings.IndexAny(hostport, "/?#"); cut >= 0 { | |
| 100 | + | hostport = hostport[:cut] | |
| 101 | + | } | |
| 102 | + | hostport = strings.TrimSuffix(hostport, ".") | |
| 103 | + | ||
| 104 | + | if h, p, err := net.SplitHostPort(hostport); err == nil { | |
| 105 | + | return h, p, false, "", h != "" | |
| 106 | + | } | |
| 107 | + | if strings.Count(hostport, ":") == 1 { | |
| 108 | + | if h, p, ok := strings.Cut(hostport, ":"); ok && h != "" && p != "" { | |
| 109 | + | return h, p, false, "", true | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | return hostport, "", false, "", hostport != "" | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func normalizeHost(host string) string { | |
| 116 | + | host = strings.TrimSpace(host) | |
| 117 | + | host = strings.Trim(host, "[]") | |
| 118 | + | host = strings.TrimSuffix(host, ".") | |
| 119 | + | return strings.ToLower(host) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func stripWWW(host string) string { | |
| 123 | + | return strings.TrimPrefix(host, "www.") | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func isDefaultPort(scheme, port string) bool { | |
| 127 | + | return (scheme == "http" && port == "80") || (scheme == "https" && port == "443") | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func isExactHost(host string) bool { | |
| 131 | + | return host == "localhost" || net.ParseIP(host) != nil | |
| 132 | + | } | |
A internal/domain/favicon/key_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package favicon | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestKeyCandidates(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | raw string | |
| 9 | + | want []Key | |
| 10 | + | }{ | |
| 11 | + | {name: "walks public host to registrable domain", raw: "https://app.docs.example.com/path", want: []Key{"app.docs.example.com", "docs.example.com", "example.com"}}, | |
| 12 | + | {name: "strips www", raw: "https://www.example.com", want: []Key{"example.com"}}, | |
| 13 | + | {name: "localhost with port is exact", raw: "http://localhost:1455/path", want: []Key{"localhost:1455"}}, | |
| 14 | + | {name: "ip with port is exact", raw: "http://127.0.0.1:8080/path", want: []Key{"127.0.0.1:8080"}}, | |
| 15 | + | {name: "ipv6 with port is exact", raw: "http://[::1]:8080/path", want: []Key{"[::1]:8080"}}, | |
| 16 | + | {name: "uses public suffix for co uk", raw: "https://news.bbc.co.uk/story", want: []Key{"news.bbc.co.uk", "bbc.co.uk"}}, | |
| 17 | + | ||
| 18 | + | {name: "drops default https port", raw: "https://example.com:443/path", want: []Key{"example.com"}}, | |
| 19 | + | {name: "non default url port is exact", raw: "https://example.com:8443/path", want: []Key{"example.com:8443"}}, | |
| 20 | + | {name: "host-like string lowercases and trims trailing dot", raw: "Docs.Example.COM.", want: []Key{"docs.example.com", "example.com"}}, | |
| 21 | + | {name: "strips www before preserving non default port", raw: "https://www.example.com:8443", want: []Key{"example.com:8443"}}, | |
| 22 | + | {name: "host-like string with port preserves port exact", raw: "example.com:443", want: []Key{"example.com:443"}}, | |
| 23 | + | {name: "unicode host normalizes to punycode", raw: "https://bücher.example/path", want: []Key{"xn--bcher-kva.example"}}, | |
| 24 | + | {name: "punycode equivalent matches unicode", raw: "https://xn--bcher-kva.example/path", want: []Key{"xn--bcher-kva.example"}}, | |
| 25 | + | {name: "empty input has no candidates", raw: "", want: nil}, | |
| 26 | + | {name: "about url has no candidates", raw: "about:blank", want: nil}, | |
| 27 | + | {name: "unsupported scheme has no candidates", raw: "file:///tmp/icon.html", want: nil}, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | for _, tt := range tests { | |
| 31 | + | t.Run(tt.name, func(t *testing.T) { | |
| 32 | + | got := Candidates(tt.raw) | |
| 33 | + | if len(got) != len(tt.want) { | |
| 34 | + | t.Fatalf("Candidates(%q) len = %d (%v), want %d (%v)", tt.raw, len(got), got, len(tt.want), tt.want) | |
| 35 | + | } | |
| 36 | + | for i := range got { | |
| 37 | + | if got[i] != tt.want[i] { | |
| 38 | + | t.Fatalf("Candidates(%q)[%d] = %q, want %q (all got %v)", tt.raw, i, got[i], tt.want[i], got) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestCanonicalKey(t *testing.T) { | |
| 46 | + | key, ok := CanonicalKey("https://www.Example.COM.:443/path") | |
| 47 | + | if !ok { | |
| 48 | + | t.Fatal("CanonicalKey returned false") | |
| 49 | + | } | |
| 50 | + | if key != "example.com" { | |
| 51 | + | t.Fatalf("CanonicalKey = %q, want example.com", key) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | if key, ok := CanonicalKey("about:blank"); ok || key != "" { | |
| 55 | + | t.Fatalf("CanonicalKey(about:blank) = %q, %v; want empty false", key, ok) | |
| 56 | + | } | |
| 57 | + | } | |
A internal/domain/favicon/metadata.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | package favicon | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | type Source string | |
| 6 | + | ||
| 7 | + | const ( | |
| 8 | + | SourceEngine Source = "engine" | |
| 9 | + | SourcePageDiscovery Source = "page_discovery" | |
| 10 | + | SourceDuckDuckGo Source = "duckduckgo" | |
| 11 | + | SourceInternal Source = "internal" | |
| 12 | + | SourceManual Source = "manual" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type Metadata struct { | |
| 16 | + | Key Key | |
| 17 | + | SourceURL string | |
| 18 | + | PageURL string | |
| 19 | + | Source Source | |
| 20 | + | ContentHash string | |
| 21 | + | ContentType string | |
| 22 | + | UpdatedAt time.Time | |
| 23 | + | LastCheckedAt time.Time | |
| 24 | + | } | |
A internal/domain/favicon/policy.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package favicon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "encoding/hex" | |
| 6 | + | "time" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const DefaultTTL = 30 * 24 * time.Hour | |
| 10 | + | ||
| 11 | + | func Hash(data []byte) string { | |
| 12 | + | sum := sha256.Sum256(data) | |
| 13 | + | return hex.EncodeToString(sum[:]) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func ShouldRefresh(meta *Metadata, now time.Time, ttl time.Duration) bool { | |
| 17 | + | if meta == nil { | |
| 18 | + | return true | |
| 19 | + | } | |
| 20 | + | if ttl <= 0 { | |
| 21 | + | return true | |
| 22 | + | } | |
| 23 | + | return !meta.LastCheckedAt.Add(ttl).After(now) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func HasContentChanged(meta *Metadata, data []byte) bool { | |
| 27 | + | if meta == nil { | |
| 28 | + | return true | |
| 29 | + | } | |
| 30 | + | return meta.ContentHash != Hash(data) | |
| 31 | + | } | |
A internal/domain/favicon/policy_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package favicon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestShouldRefresh(t *testing.T) { | |
| 9 | + | now := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC) | |
| 10 | + | ttl := 30 * 24 * time.Hour | |
| 11 | + | ||
| 12 | + | if !ShouldRefresh(nil, now, ttl) { | |
| 13 | + | t.Fatal("missing metadata should refresh") | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | fresh := &Metadata{LastCheckedAt: now.Add(-ttl + time.Minute)} | |
| 17 | + | if ShouldRefresh(fresh, now, ttl) { | |
| 18 | + | t.Fatal("fresh metadata should not refresh") | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | stale := &Metadata{LastCheckedAt: now.Add(-ttl - time.Minute)} | |
| 22 | + | if !ShouldRefresh(stale, now, ttl) { | |
| 23 | + | t.Fatal("stale metadata should refresh") | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestContentHashChanged(t *testing.T) { | |
| 28 | + | data := []byte("favicon bytes") | |
| 29 | + | meta := &Metadata{ContentHash: Hash(data)} | |
| 30 | + | ||
| 31 | + | if HasContentChanged(meta, data) { | |
| 32 | + | t.Fatal("same bytes should not be changed") | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | if !HasContentChanged(meta, []byte("different bytes")) { | |
| 36 | + | t.Fatal("different bytes should be changed") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | if !HasContentChanged(nil, data) { | |
| 40 | + | t.Fatal("missing metadata should be treated as changed") | |
| 41 | + | } | |
| 42 | + | } | |
A internal/infrastructure/cef/favicon_discovery.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/url" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "golang.org/x/net/html" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // DiscoverFaviconCandidates extracts ordered favicon URL candidates from page | |
| 11 | + | // metadata and appends the origin /favicon.ico fallback. Only http(s) page and | |
| 12 | + | // icon URLs are returned; duplicates are removed while preserving order. | |
| 13 | + | func DiscoverFaviconCandidates(pageURL, htmlSource string) []string { | |
| 14 | + | candidates := make([]string, 0, 4) | |
| 15 | + | for _, href := range parseFaviconHrefs(htmlSource) { | |
| 16 | + | if resolved, ok := resolveFaviconURL(pageURL, href); ok { | |
| 17 | + | candidates = append(candidates, resolved) | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | if fallback, ok := originFaviconURL(pageURL); ok { | |
| 21 | + | candidates = append(candidates, fallback) | |
| 22 | + | } | |
| 23 | + | if len(candidates) == 0 { | |
| 24 | + | return nil | |
| 25 | + | } | |
| 26 | + | return dedupeStrings(candidates) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func resolveFaviconCandidates(pageURL string, rawIconURLs []string) []string { | |
| 30 | + | candidates := make([]string, 0, len(rawIconURLs)) | |
| 31 | + | for _, raw := range rawIconURLs { | |
| 32 | + | if resolved, ok := resolveFaviconURL(pageURL, raw); ok { | |
| 33 | + | candidates = append(candidates, resolved) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | return dedupeStrings(candidates) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func fallbackFaviconCandidates(pageURL string) []string { | |
| 40 | + | fallback, ok := originFaviconURL(pageURL) | |
| 41 | + | if !ok { | |
| 42 | + | return nil | |
| 43 | + | } | |
| 44 | + | return []string{fallback} | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func parseFaviconHrefs(htmlSource string) []string { | |
| 48 | + | z := html.NewTokenizer(strings.NewReader(htmlSource)) | |
| 49 | + | var hrefs []string | |
| 50 | + | for { | |
| 51 | + | switch z.Next() { | |
| 52 | + | case html.ErrorToken: | |
| 53 | + | return hrefs | |
| 54 | + | case html.StartTagToken, html.SelfClosingTagToken: | |
| 55 | + | tn, hasAttr := z.TagName() | |
| 56 | + | if !hasAttr || !strings.EqualFold(string(tn), "link") { | |
| 57 | + | continue | |
| 58 | + | } | |
| 59 | + | var rel, href string | |
| 60 | + | for { | |
| 61 | + | key, val, more := z.TagAttr() | |
| 62 | + | switch strings.ToLower(string(key)) { | |
| 63 | + | case "rel": | |
| 64 | + | rel = string(val) | |
| 65 | + | case "href": | |
| 66 | + | href = string(val) | |
| 67 | + | } | |
| 68 | + | if !more { | |
| 69 | + | break | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | if href != "" && faviconRel(rel) { | |
| 73 | + | hrefs = append(hrefs, href) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func faviconRel(rel string) bool { | |
| 80 | + | fields := strings.Fields(strings.ToLower(rel)) | |
| 81 | + | for _, field := range fields { | |
| 82 | + | if field == "icon" || field == "apple-touch-icon" || field == "apple-touch-icon-precomposed" { | |
| 83 | + | return true | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | return false | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func resolveFaviconURL(pageURL, rawIconURL string) (string, bool) { | |
| 90 | + | icon, err := url.Parse(strings.TrimSpace(rawIconURL)) | |
| 91 | + | if err != nil || rawIconURL == "" { | |
| 92 | + | return "", false | |
| 93 | + | } | |
| 94 | + | if icon.IsAbs() { | |
| 95 | + | if !isHTTPScheme(icon.Scheme) || icon.Host == "" { | |
| 96 | + | return "", false | |
| 97 | + | } | |
| 98 | + | return icon.String(), true | |
| 99 | + | } | |
| 100 | + | base, err := url.Parse(pageURL) | |
| 101 | + | if err != nil || !isHTTPScheme(base.Scheme) || base.Host == "" { | |
| 102 | + | return "", false | |
| 103 | + | } | |
| 104 | + | resolved := base.ResolveReference(icon) | |
| 105 | + | if !isHTTPScheme(resolved.Scheme) || resolved.Host == "" { | |
| 106 | + | return "", false | |
| 107 | + | } | |
| 108 | + | return resolved.String(), true | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func originFaviconURL(pageURL string) (string, bool) { | |
| 112 | + | u, err := url.Parse(pageURL) | |
| 113 | + | if err != nil || !isHTTPScheme(u.Scheme) || u.Host == "" { | |
| 114 | + | return "", false | |
| 115 | + | } | |
| 116 | + | u.Path = "/favicon.ico" | |
| 117 | + | u.RawPath = "" | |
| 118 | + | u.RawQuery = "" | |
| 119 | + | u.Fragment = "" | |
| 120 | + | return u.String(), true | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func isHTTPScheme(scheme string) bool { | |
| 124 | + | return scheme == "http" || scheme == "https" | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func dedupeStrings(values []string) []string { | |
| 128 | + | seen := make(map[string]struct{}, len(values)) | |
| 129 | + | out := make([]string, 0, len(values)) | |
| 130 | + | for _, value := range values { | |
| 131 | + | if value == "" { | |
| 132 | + | continue | |
| 133 | + | } | |
| 134 | + | if _, ok := seen[value]; ok { | |
| 135 | + | continue | |
| 136 | + | } | |
| 137 | + | seen[value] = struct{}{} | |
| 138 | + | out = append(out, value) | |
| 139 | + | } | |
| 140 | + | return out | |
| 141 | + | } | |
A internal/infrastructure/cef/favicon_discovery_test.go +103 -0
| @@ -0,0 +1,103 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestFaviconDiscoveryResolvesLinksAndFallback(t *testing.T) { | |
| 15 | + | pageURL := "https://example.com/docs/page.html" | |
| 16 | + | html := `<html><head> | |
| 17 | + | <link rel="stylesheet" href="/style.css"> | |
| 18 | + | <link rel="icon" href="/favicon.png"> | |
| 19 | + | <link rel="shortcut icon" href="icons/shortcut.ico"> | |
| 20 | + | <link rel="apple-touch-icon" href="https://cdn.example.com/apple.png"> | |
| 21 | + | <link rel="icon" href="javascript:alert(1)"> | |
| 22 | + | </head></html>` | |
| 23 | + | ||
| 24 | + | got := DiscoverFaviconCandidates(pageURL, html) | |
| 25 | + | require.Equal(t, []string{ | |
| 26 | + | "https://example.com/favicon.png", | |
| 27 | + | "https://example.com/docs/icons/shortcut.ico", | |
| 28 | + | "https://cdn.example.com/apple.png", | |
| 29 | + | "https://example.com/favicon.ico", | |
| 30 | + | }, got) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestFaviconDiscoveryFallbackOnly(t *testing.T) { | |
| 34 | + | require.Equal(t, []string{"https://example.com/favicon.ico"}, DiscoverFaviconCandidates("https://example.com/path", "<html></html>")) | |
| 35 | + | require.Nil(t, DiscoverFaviconCandidates("about:blank", "<html></html>")) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestFaviconDiscoveryKeepsAbsoluteIconsForOpaquePageURL(t *testing.T) { | |
| 39 | + | got := DiscoverFaviconCandidates("about:blank", `<link rel="icon" href="https://cdn.example.com/icon.png">`) | |
| 40 | + | require.Equal(t, []string{"https://cdn.example.com/icon.png"}, got) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestFaviconDiscoveryIgnoresUnsafeIconURLs(t *testing.T) { | |
| 44 | + | got := DiscoverFaviconCandidates("https://example.com/path", `<link rel="icon" href="data:image/png;base64,AAAA"><link rel="icon" href="//cdn.example.com/icon.png">`) | |
| 45 | + | require.Equal(t, []string{"https://cdn.example.com/icon.png", "https://example.com/favicon.ico"}, got) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestCEFOnFaviconUrlchangeForwardsOrderedCandidates(t *testing.T) { | |
| 49 | + | prevDecode := decodeCEFStringList | |
| 50 | + | decodeCEFStringList = func(list purecef.StringList) []string { | |
| 51 | + | require.Equal(t, purecef.StringList(42), list) | |
| 52 | + | return []string{"/favicon.png", "https://cdn.example.com/icon.ico", "javascript:alert(1)"} | |
| 53 | + | } | |
| 54 | + | defer func() { decodeCEFStringList = prevDecode }() | |
| 55 | + | ||
| 56 | + | var page string | |
| 57 | + | var icons []string | |
| 58 | + | wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{ | |
| 59 | + | OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls }, | |
| 60 | + | }} | |
| 61 | + | ||
| 62 | + | (&handlerSet{wv: wv}).OnFaviconUrlchange(nil, purecef.StringList(42)) | |
| 63 | + | ||
| 64 | + | require.Equal(t, "https://example.com/docs/page", page) | |
| 65 | + | require.Equal(t, []string{"https://example.com/favicon.png", "https://cdn.example.com/icon.ico"}, icons) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestCEFLoadingFinishedDiscoversMetadataBeforeFallback(t *testing.T) { | |
| 69 | + | prevNewStringVisitor := cefNewStringVisitor | |
| 70 | + | cefNewStringVisitor = func(visitor purecef.StringVisitor) purecef.StringVisitor { return visitor } | |
| 71 | + | defer func() { cefNewStringVisitor = prevNewStringVisitor }() | |
| 72 | + | ||
| 73 | + | browser := cefmocks.NewMockBrowser(t) | |
| 74 | + | frame := cefmocks.NewMockFrame(t) | |
| 75 | + | browser.EXPECT().GetMainFrame().Return(frame) | |
| 76 | + | frame.EXPECT().GetSource(mock.Anything).Run(func(visitor purecef.StringVisitor) { | |
| 77 | + | visitor.Visit(`<html><head><link rel="icon" href="/meta.png"></head></html>`) | |
| 78 | + | }).Return() | |
| 79 | + | ||
| 80 | + | var page string | |
| 81 | + | var icons []string | |
| 82 | + | wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{ | |
| 83 | + | OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls }, | |
| 84 | + | }} | |
| 85 | + | ||
| 86 | + | (&handlerSet{wv: wv}).OnLoadingStateChange(browser, 0, 1, 0) | |
| 87 | + | ||
| 88 | + | require.Equal(t, "https://example.com/docs/page", page) | |
| 89 | + | require.Equal(t, []string{"https://example.com/meta.png", "https://example.com/favicon.ico"}, icons) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func TestCEFLoadingFinishedForwardsFallbackCandidate(t *testing.T) { | |
| 93 | + | var page string | |
| 94 | + | var icons []string | |
| 95 | + | wv := &WebView{ctx: context.Background(), uri: "https://example.com/docs/page", callbacks: &port.WebViewCallbacks{ | |
| 96 | + | OnFaviconURLChanged: func(p string, urls []string) { page, icons = p, urls }, | |
| 97 | + | }} | |
| 98 | + | ||
| 99 | + | (&handlerSet{wv: wv}).OnLoadingStateChange(nil, 0, 1, 0) | |
| 100 | + | ||
| 101 | + | require.Equal(t, "https://example.com/docs/page", page) | |
| 102 | + | require.Equal(t, []string{"https://example.com/favicon.ico"}, icons) | |
| 103 | + | } | |
M internal/infrastructure/cef/favicon_systemviews.go +25 -7
| @@ -1,15 +1,33 @@ | |||
| 1 | 1 | 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 internal | |
| 8 | - | // systemview pages. The scheme handler only serves cached PNGs; it does not | |
| 9 | - | // fetch remote favicons on behalf of History rendering. | |
| 10 | - | func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) { | |
| 10 | + | var _ port.SystemviewFaviconResolverSetter = (*Engine)(nil) | |
| 11 | + | var _ port.FaviconInvalidator = (*Engine)(nil) | |
| 12 | + | ||
| 13 | + | // SetSystemviewFaviconResolver exposes the application favicon resolver to | |
| 14 | + | // internal systemview pages without giving engine code disk-service access. | |
| 15 | + | func (e *Engine) SetSystemviewFaviconResolver(resolver port.FaviconSystemviewResolver) { | |
| 11 | 16 | if e == nil || e.schemeHandler == nil { | |
| 12 | 17 | return | |
| 13 | 18 | } | |
| 14 | - | e.schemeHandler.setFaviconService(service) | |
| 19 | + | e.schemeHandler.setFaviconResolver(resolver) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (e *Engine) Invalidate(ctx context.Context, key favicon.Key) error { | |
| 23 | + | if e == nil || e.schemeHandler == nil { | |
| 24 | + | return nil | |
| 25 | + | } | |
| 26 | + | return e.schemeHandler.invalidateFavicon(ctx, key) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (h *dumbSchemeHandler) invalidateFavicon(_ context.Context, _ favicon.Key) error { | |
| 30 | + | // CEF systemview favicon requests resolve through the application usecase on | |
| 31 | + | // each request, so this adapter does not own a favicon response cache. | |
| 32 | + | return nil | |
| 15 | 33 | } | |
M internal/infrastructure/cef/scheme_handler.go +29 -113
| @@ -12,7 +12,6 @@ import ( | |||
| 12 | 12 | "io/fs" | |
| 13 | 13 | "net/http" | |
| 14 | 14 | "net/url" | |
| 15 | - | "os" | |
| 16 | 15 | "path" | |
| 17 | 16 | "strconv" | |
| 18 | 17 | "strings" | |
| @@ -23,7 +22,6 @@ import ( | |||
| 23 | 22 | purecef "github.com/bnema/purego-cef/cef" | |
| 24 | 23 | ||
| 25 | 24 | "github.com/bnema/dumber/internal/application/port" | |
| 26 | - | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 27 | 25 | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 28 | 26 | "github.com/bnema/dumber/internal/logging" | |
| 29 | 27 | "github.com/rs/zerolog" | |
| @@ -56,7 +54,7 @@ var cefNewResourceHandler = purecef.NewResourceHandler | |||
| 56 | 54 | type dumbSchemeHandler struct { | |
| 57 | 55 | ctx context.Context | |
| 58 | 56 | messageRouter *MessageRouter | |
| 59 | - | faviconService port.FaviconService | |
| 57 | + | faviconResolver port.FaviconSystemviewResolver | |
| 60 | 58 | assets embed.FS | |
| 61 | 59 | assetsSet bool | |
| 62 | 60 | assetDir string | |
| @@ -121,10 +119,10 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) { | |||
| 121 | 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 | 123 | h.mu.Lock() | |
| 126 | 124 | defer h.mu.Unlock() | |
| 127 | - | h.faviconService = service | |
| 125 | + | h.faviconResolver = resolver | |
| 128 | 126 | } | |
| 129 | 127 | ||
| 130 | 128 | // 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 | 311 | h.mu.RLock() | |
| 314 | - | service := h.faviconService | |
| 312 | + | resolver := h.faviconResolver | |
| 315 | 313 | h.mu.RUnlock() | |
| 316 | - | if service == nil { | |
| 314 | + | if resolver == nil { | |
| 317 | 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 | 319 | if err != nil { | |
| 322 | 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 | 323 | if domain == "" { | |
| 326 | 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 | 332 | 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 | 338 | func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler { | |
| @@ -833,91 +831,25 @@ func (h *dumbSchemeHandler) newPrivateAPIJSONResourceHandler(status int, v any) | |||
| 833 | 831 | return h.newPrivateAPIRawResourceHandler(status, "application/json", data) | |
| 834 | 832 | } | |
| 835 | 833 | ||
| 836 | - | func newFaviconResourceHandler(service port.FaviconService, domain string, size int) purecef.ResourceHandler { | |
| 834 | + | func newFaviconResourceHandler( | |
| 835 | + | ctx context.Context, | |
| 836 | + | resolver port.FaviconSystemviewResolver, | |
| 837 | + | domain string, | |
| 838 | + | size int, | |
| 839 | + | ) purecef.ResourceHandler { | |
| 837 | 840 | return cefNewResourceHandler(&faviconResourceHandler{ | |
| 838 | - | service: service, | |
| 839 | - | domain: domain, | |
| 840 | - | size: size, | |
| 841 | - | done: make(chan struct{}), | |
| 842 | - | headers: map[string]string{"Cache-Control": "no-store"}, | |
| 841 | + | ctx: ctx, | |
| 842 | + | resolver: resolver, | |
| 843 | + | domain: domain, | |
| 844 | + | size: size, | |
| 845 | + | done: make(chan struct{}), | |
| 846 | + | headers: map[string]string{"Cache-Control": "no-store"}, | |
| 843 | 847 | }) | |
| 844 | 848 | } | |
| 845 | 849 | ||
| 846 | - | const maxFaviconMemoryCacheEntries = 256 | |
| 847 | - | ||
| 848 | - | var systemviewFaviconCache = newFaviconMemoryCache(maxFaviconMemoryCacheEntries) | |
| 849 | - | ||
| 850 | - | type faviconCacheEntry struct { | |
| 851 | - | statusCode int | |
| 852 | - | contentType string | |
| 853 | - | data []byte | |
| 854 | - | } | |
| 855 | - | ||
| 856 | - | type faviconMemoryCache struct { | |
| 857 | - | mu sync.Mutex | |
| 858 | - | max int | |
| 859 | - | entries map[string]faviconCacheEntry | |
| 860 | - | order []string | |
| 861 | - | } | |
| 862 | - | ||
| 863 | - | func newFaviconMemoryCache(maxEntries int) *faviconMemoryCache { | |
| 864 | - | return &faviconMemoryCache{ | |
| 865 | - | max: maxEntries, | |
| 866 | - | entries: make(map[string]faviconCacheEntry), | |
| 867 | - | order: make([]string, 0, maxEntries), | |
| 868 | - | } | |
| 869 | - | } | |
| 870 | - | ||
| 871 | - | func faviconCacheKey(domain string, size int) string { | |
| 872 | - | return strings.ToLower(strings.TrimSpace(domain)) + "\x00" + strconv.Itoa(size) | |
| 873 | - | } | |
| 874 | - | ||
| 875 | - | func (c *faviconMemoryCache) get(key string) (faviconCacheEntry, bool) { | |
| 876 | - | if c == nil || key == "" { | |
| 877 | - | return faviconCacheEntry{}, false | |
| 878 | - | } | |
| 879 | - | c.mu.Lock() | |
| 880 | - | defer c.mu.Unlock() | |
| 881 | - | entry, ok := c.entries[key] | |
| 882 | - | if !ok { | |
| 883 | - | return faviconCacheEntry{}, false | |
| 884 | - | } | |
| 885 | - | c.moveToBackLocked(key) | |
| 886 | - | return entry, true | |
| 887 | - | } | |
| 888 | - | ||
| 889 | - | func (c *faviconMemoryCache) put(key string, entry faviconCacheEntry) { | |
| 890 | - | if c == nil || key == "" || c.max <= 0 { | |
| 891 | - | return | |
| 892 | - | } | |
| 893 | - | c.mu.Lock() | |
| 894 | - | defer c.mu.Unlock() | |
| 895 | - | if _, ok := c.entries[key]; !ok { | |
| 896 | - | c.order = append(c.order, key) | |
| 897 | - | } else { | |
| 898 | - | c.moveToBackLocked(key) | |
| 899 | - | } | |
| 900 | - | c.entries[key] = entry | |
| 901 | - | for len(c.order) > c.max { | |
| 902 | - | oldest := c.order[0] | |
| 903 | - | c.order = c.order[1:] | |
| 904 | - | delete(c.entries, oldest) | |
| 905 | - | } | |
| 906 | - | } | |
| 907 | - | ||
| 908 | - | func (c *faviconMemoryCache) moveToBackLocked(key string) { | |
| 909 | - | for i, candidate := range c.order { | |
| 910 | - | if candidate != key { | |
| 911 | - | continue | |
| 912 | - | } | |
| 913 | - | copy(c.order[i:], c.order[i+1:]) | |
| 914 | - | c.order[len(c.order)-1] = key | |
| 915 | - | return | |
| 916 | - | } | |
| 917 | - | } | |
| 918 | - | ||
| 919 | 850 | type faviconResourceHandler struct { | |
| 920 | - | service port.FaviconService | |
| 851 | + | ctx context.Context | |
| 852 | + | resolver port.FaviconSystemviewResolver | |
| 921 | 853 | domain string | |
| 922 | 854 | size int | |
| 923 | 855 | headers map[string]string | |
| @@ -934,35 +866,19 @@ func (rh *faviconResourceHandler) load() { | |||
| 934 | 866 | rh.statusCode = http.StatusNotFound | |
| 935 | 867 | rh.contentType = "application/json" | |
| 936 | 868 | rh.data = []byte(`{"error":"favicon not cached"}`) | |
| 937 | - | if rh.service == nil { | |
| 938 | - | return | |
| 939 | - | } | |
| 940 | - | cacheKey := faviconCacheKey(rh.domain, rh.size) | |
| 941 | - | if entry, ok := systemviewFaviconCache.get(cacheKey); ok { | |
| 942 | - | rh.statusCode = entry.statusCode | |
| 943 | - | rh.contentType = entry.contentType | |
| 944 | - | rh.data = entry.data | |
| 945 | - | return | |
| 946 | - | } | |
| 947 | - | if !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) { | |
| 869 | + | if rh.resolver == nil { | |
| 948 | 870 | return | |
| 949 | 871 | } | |
| 950 | - | diskPath := rh.service.DiskPathPNGSized(rh.domain, rh.size) | |
| 951 | - | if diskPath == "" { | |
| 952 | - | return | |
| 953 | - | } | |
| 954 | - | data, err := os.ReadFile(diskPath) | |
| 955 | - | if err != nil || len(data) == 0 { | |
| 872 | + | resolved, err := rh.resolver.ResolveSystemviewIcon(rh.ctx, rh.domain, rh.size) | |
| 873 | + | if err != nil || resolved == nil || len(resolved.Bytes) == 0 { | |
| 956 | 874 | return | |
| 957 | 875 | } | |
| 958 | 876 | rh.statusCode = http.StatusOK | |
| 959 | - | rh.contentType = "image/png" | |
| 960 | - | rh.data = data | |
| 961 | - | systemviewFaviconCache.put(cacheKey, faviconCacheEntry{ | |
| 962 | - | statusCode: rh.statusCode, | |
| 963 | - | contentType: rh.contentType, | |
| 964 | - | data: data, | |
| 965 | - | }) | |
| 877 | + | rh.contentType = resolved.ContentType | |
| 878 | + | if rh.contentType == "" { | |
| 879 | + | rh.contentType = "image/png" | |
| 880 | + | } | |
| 881 | + | rh.data = resolved.Bytes | |
| 966 | 882 | } | |
| 967 | 883 | ||
| 968 | 884 | func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 { | |
M internal/infrastructure/cef/scheme_handler_test.go +5 -18
| @@ -10,6 +10,8 @@ import ( | |||
| 10 | 10 | purecef "github.com/bnema/purego-cef/cef" | |
| 11 | 11 | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/dumber/internal/application/port" | |
| 13 | 15 | ) | |
| 14 | 16 | ||
| 15 | 17 | func expectAPIResponseHeaders(response *cefmocks.MockResponse) { | |
| @@ -28,26 +30,11 @@ func expectPrivateAPIResponseHeaders(response *cefmocks.MockResponse) { | |||
| 28 | 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) { | |
| 34 | - | panic("unexpected favicon lookup") | |
| 35 | - | } | |
| 36 | - | func (panicFaviconService) Get(context.Context, string) ([]byte, error) { | |
| 37 | - | panic("unexpected favicon lookup") | |
| 38 | - | } | |
| 39 | - | func (panicFaviconService) DiskPathPNG(string) string { panic("unexpected favicon lookup") } | |
| 40 | - | func (panicFaviconService) HasPNGOnDisk(string) bool { panic("unexpected favicon lookup") } | |
| 41 | - | func (panicFaviconService) HasPNGSizedOnDisk(string, int) bool { panic("unexpected favicon lookup") } | |
| 42 | - | func (panicFaviconService) DiskPathPNGSized(string, int) string { panic("unexpected favicon lookup") } | |
| 43 | - | func (panicFaviconService) EnsureSizedPNG(context.Context, string, int) error { | |
| 44 | - | panic("unexpected favicon lookup") | |
| 45 | - | } | |
| 46 | - | func (panicFaviconService) EnsureCacheDir() error { panic("unexpected favicon lookup") } | |
| 47 | - | func (panicFaviconService) EnsureDiskCache(context.Context, string) { | |
| 35 | + | func (panicSystemviewFaviconResolver) ResolveSystemviewIcon(context.Context, string, int) (*port.ResolvedFavicon, error) { | |
| 48 | 36 | panic("unexpected favicon lookup") | |
| 49 | 37 | } | |
| 50 | - | func (panicFaviconService) Close() {} | |
| 51 | 38 | ||
| 52 | 39 | // noopConfigPayload returns a builder that always returns an empty JSON object. | |
| 53 | 40 | func noopConfigPayload() func() ([]byte, error) { | |
| @@ -310,7 +297,7 @@ func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T) | |||
| 310 | 297 | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |
| 311 | 298 | ||
| 312 | 299 | h := newTestDumbSchemeHandler(t) | |
| 313 | - | h.setFaviconService(panicFaviconService{}) | |
| 300 | + | h.setFaviconResolver(panicSystemviewFaviconResolver{}) | |
| 314 | 301 | ||
| 315 | 302 | request := cefmocks.NewMockRequest(t) | |
| 316 | 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 | 69 | var ( | |
| 70 | - | cefNewTask = purecef.NewTask | |
| 71 | - | cefPostTask = purecef.PostTask | |
| 72 | - | cefPostDelayedTask = purecef.PostDelayedTask | |
| 73 | - | cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) } | |
| 70 | + | cefNewTask = purecef.NewTask | |
| 71 | + | cefNewStringVisitor = purecef.NewStringVisitor | |
| 72 | + | cefPostTask = purecef.PostTask | |
| 73 | + | cefPostDelayedTask = purecef.PostDelayedTask | |
| 74 | + | cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) } | |
| 74 | 75 | ) | |
| 75 | 76 | ||
| 76 | 77 | // WebView implements port.WebView using a CEF off-screen browser rendered and | |
| @@ -199,6 +200,11 @@ type WebView struct { | |||
| 199 | 200 | // Last known hover URI for middle-click → new tab. | |
| 200 | 201 | 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 | 208 | // Load diagnostics state (mutex-protected). | |
| 203 | 209 | loadDiagSeq uint64 | |
| 204 | 210 | loadDiagStartedAt time.Time | |
M internal/infrastructure/cef/webview_handlers.go +110 -2
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "strings" | |
| 6 | 6 | "sync" | |
| 7 | + | "sync/atomic" | |
| 8 | + | "time" | |
| 7 | 9 | ||
| 8 | 10 | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | 11 | ||
| @@ -176,7 +178,25 @@ func (h *handlerSet) OnTitleChange(_ purecef.Browser, title string) { | |||
| 176 | 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 | 201 | // OnFullscreenModeChange toggles the fullscreen atomic and fires callbacks. | |
| 182 | 202 | func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32) { | |
| @@ -285,8 +305,23 @@ func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect) | |||
| 285 | 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 | 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 | 325 | loading := isloading != 0 | |
| 291 | 326 | h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0) | |
| 292 | 327 | if h.wv != nil && h.wv.ctx != nil { | |
| @@ -320,7 +355,11 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac | |||
| 320 | 355 | ||
| 321 | 356 | h.wv.mu.RLock() | |
| 322 | 357 | cb := h.wv.callbacks | |
| 358 | + | pageURL := h.wv.uri | |
| 323 | 359 | h.wv.mu.RUnlock() | |
| 360 | + | if !loading && cb != nil && cb.OnFaviconURLChanged != nil { | |
| 361 | + | h.emitDiscoveredFaviconCandidates(browser, pageURL, cb) | |
| 362 | + | } | |
| 324 | 363 | if cb != nil && cb.OnLoadChanged != nil { | |
| 325 | 364 | if loading { | |
| 326 | 365 | h.wv.runOnGTK(func() { | |
| @@ -334,6 +373,75 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac | |||
| 334 | 373 | } | |
| 335 | 374 | } | |
| 336 | 375 | ||
| 376 | + | var faviconSourceVisitorRetentionID atomic.Uint64 | |
| 377 | + | ||
| 378 | + | func (wv *WebView) retainFaviconSourceVisitor(visitor purecef.StringVisitor) func() { | |
| 379 | + | if wv == nil || visitor == nil { | |
| 380 | + | return func() {} | |
| 381 | + | } | |
| 382 | + | id := faviconSourceVisitorRetentionID.Add(1) | |
| 383 | + | wv.faviconSourceVisitorsMu.Lock() | |
| 384 | + | wv.faviconSourceVisitors = append(wv.faviconSourceVisitors, faviconSourceVisitorRetention{id: id, visitor: visitor}) | |
| 385 | + | wv.faviconSourceVisitorsMu.Unlock() | |
| 386 | + | return func() { | |
| 387 | + | wv.faviconSourceVisitorsMu.Lock() | |
| 388 | + | defer wv.faviconSourceVisitorsMu.Unlock() | |
| 389 | + | for i, candidate := range wv.faviconSourceVisitors { | |
| 390 | + | if candidate.id == id { | |
| 391 | + | wv.faviconSourceVisitors = append(wv.faviconSourceVisitors[:i], wv.faviconSourceVisitors[i+1:]...) | |
| 392 | + | return | |
| 393 | + | } | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pageURL string, cb *port.WebViewCallbacks) { | |
| 399 | + | if h == nil || h.wv == nil || cb == nil || cb.OnFaviconURLChanged == nil { | |
| 400 | + | return | |
| 401 | + | } | |
| 402 | + | fallback := fallbackFaviconCandidates(pageURL) | |
| 403 | + | emit := func(candidates []string) { | |
| 404 | + | if len(candidates) == 0 { | |
| 405 | + | return | |
| 406 | + | } | |
| 407 | + | h.wv.runOnGTK(func() { | |
| 408 | + | cb.OnFaviconURLChanged(pageURL, candidates) | |
| 409 | + | }) | |
| 410 | + | } | |
| 411 | + | if browser == nil { | |
| 412 | + | emit(fallback) | |
| 413 | + | return | |
| 414 | + | } | |
| 415 | + | frame := browser.GetMainFrame() | |
| 416 | + | if frame == nil { | |
| 417 | + | emit(fallback) | |
| 418 | + | return | |
| 419 | + | } | |
| 420 | + | var releaseOnce sync.Once | |
| 421 | + | var release func() | |
| 422 | + | visitor := cefNewStringVisitor(faviconSourceVisitor{visit: func(source string) { | |
| 423 | + | releaseOnce.Do(func() { | |
| 424 | + | if release != nil { | |
| 425 | + | release() | |
| 426 | + | } | |
| 427 | + | }) | |
| 428 | + | candidates := DiscoverFaviconCandidates(pageURL, source) | |
| 429 | + | if len(candidates) == 0 { | |
| 430 | + | candidates = fallback | |
| 431 | + | } | |
| 432 | + | emit(candidates) | |
| 433 | + | }}) | |
| 434 | + | release = h.wv.retainFaviconSourceVisitor(visitor) | |
| 435 | + | time.AfterFunc(5*time.Second, func() { | |
| 436 | + | releaseOnce.Do(func() { | |
| 437 | + | if release != nil { | |
| 438 | + | release() | |
| 439 | + | } | |
| 440 | + | }) | |
| 441 | + | }) | |
| 442 | + | frame.GetSource(visitor) | |
| 443 | + | } | |
| 444 | + | ||
| 337 | 445 | // OnLoadStart fires LoadCommitted for main frame navigations. | |
| 338 | 446 | // In CEF this callback runs after navigation commit, so it is the closest | |
| 339 | 447 | // equivalent to WebKit's LoadCommitted event. | |
A internal/infrastructure/favicon/blob_store.go +167 -0
| @@ -0,0 +1,167 @@ | |||
| 1 | + | package favicon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | ||
| 11 | + | appport "github.com/bnema/dumber/internal/application/port" | |
| 12 | + | domainfavicon "github.com/bnema/dumber/internal/domain/favicon" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const contentTypeSuffix = ".content-type" | |
| 16 | + | ||
| 17 | + | // BlobStore stores favicon originals and derived PNG files on disk. | |
| 18 | + | type BlobStore struct { | |
| 19 | + | cache *Cache | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func NewBlobStore(diskDir string) *BlobStore { return &BlobStore{cache: NewCache(diskDir)} } | |
| 23 | + | func NewBlobStoreFromCache(cache *Cache) *BlobStore { return &BlobStore{cache: cache} } | |
| 24 | + | ||
| 25 | + | func (s *BlobStore) ReadOriginal(ctx context.Context, key domainfavicon.Key) ([]byte, string, error) { | |
| 26 | + | _ = ctx | |
| 27 | + | path := s.cache.DiskPath(string(key)) | |
| 28 | + | data, err := readNonEmpty(path) | |
| 29 | + | if err != nil { | |
| 30 | + | return nil, "", err | |
| 31 | + | } | |
| 32 | + | ct, err := os.ReadFile(path + contentTypeSuffix) | |
| 33 | + | if err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 34 | + | return nil, "", err | |
| 35 | + | } | |
| 36 | + | return data, strings.TrimSpace(string(ct)), nil | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (s *BlobStore) WriteOriginal(ctx context.Context, key domainfavicon.Key, data []byte, contentType string) error { | |
| 40 | + | _ = ctx | |
| 41 | + | path := s.cache.DiskPath(string(key)) | |
| 42 | + | if err := atomicWrite(path, data); err != nil { | |
| 43 | + | return err | |
| 44 | + | } | |
| 45 | + | if contentType != "" { | |
| 46 | + | return atomicWrite(path+contentTypeSuffix, []byte(contentType)) | |
| 47 | + | } | |
| 48 | + | _ = os.Remove(path + contentTypeSuffix) | |
| 49 | + | return nil | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (s *BlobStore) ReadPNG(ctx context.Context, key domainfavicon.Key) ([]byte, string, error) { | |
| 53 | + | _ = ctx | |
| 54 | + | data, err := readNonEmpty(s.cache.DiskPathPNG(string(key))) | |
| 55 | + | if err != nil { | |
| 56 | + | return nil, "", err | |
| 57 | + | } | |
| 58 | + | return data, "image/png", nil | |
| 59 | + | } | |
| 60 | + | func (s *BlobStore) WritePNG(ctx context.Context, key domainfavicon.Key, data []byte) error { | |
| 61 | + | _ = ctx | |
| 62 | + | return atomicWrite(s.cache.DiskPathPNG(string(key)), data) | |
| 63 | + | } | |
| 64 | + | func (s *BlobStore) ReadSizedPNG(ctx context.Context, key domainfavicon.Key, size int) ([]byte, string, error) { | |
| 65 | + | _ = ctx | |
| 66 | + | data, err := readNonEmpty(s.cache.DiskPathPNGSized(string(key), size)) | |
| 67 | + | if err != nil { | |
| 68 | + | return nil, "", err | |
| 69 | + | } | |
| 70 | + | return data, "image/png", nil | |
| 71 | + | } | |
| 72 | + | func (s *BlobStore) WriteSizedPNG(ctx context.Context, key domainfavicon.Key, size int, data []byte) error { | |
| 73 | + | _ = ctx | |
| 74 | + | return atomicWrite(s.cache.DiskPathPNGSized(string(key), size), data) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (s *BlobStore) RemoveDerived(ctx context.Context, key domainfavicon.Key) error { | |
| 78 | + | _ = ctx | |
| 79 | + | if s.cache == nil || s.cache.diskDir == "" { | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | pngPath := s.cache.DiskPathPNG(string(key)) | |
| 83 | + | paths := []string{pngPath} | |
| 84 | + | var errs []error | |
| 85 | + | basePrefix := strings.TrimSuffix(filepath.Base(pngPath), ".png") + "." | |
| 86 | + | pattern := filepath.Join(s.cache.diskDir, basePrefix+"*.png") | |
| 87 | + | matches, globErr := filepath.Glob(pattern) | |
| 88 | + | if globErr != nil { | |
| 89 | + | errs = append(errs, globErr) | |
| 90 | + | } | |
| 91 | + | for _, match := range matches { | |
| 92 | + | if isSizedPNGForKey(filepath.Base(match), basePrefix) { | |
| 93 | + | paths = append(paths, match) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | for _, p := range paths { | |
| 97 | + | if p != "" { | |
| 98 | + | if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { | |
| 99 | + | errs = append(errs, err) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | return errors.Join(errs...) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func isSizedPNGForKey(baseName, basePrefix string) bool { | |
| 107 | + | if !strings.HasPrefix(baseName, basePrefix) || !strings.HasSuffix(baseName, ".png") { | |
| 108 | + | return false | |
| 109 | + | } | |
| 110 | + | sizePart := strings.TrimSuffix(strings.TrimPrefix(baseName, basePrefix), ".png") | |
| 111 | + | if sizePart == "" { | |
| 112 | + | return false | |
| 113 | + | } | |
| 114 | + | for _, r := range sizePart { | |
| 115 | + | if r < '0' || r > '9' { | |
| 116 | + | return false | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | return true | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func readNonEmpty(path string) ([]byte, error) { | |
| 123 | + | if path == "" { | |
| 124 | + | return nil, appport.ErrFaviconMiss | |
| 125 | + | } | |
| 126 | + | data, err := os.ReadFile(path) | |
| 127 | + | if errors.Is(err, os.ErrNotExist) { | |
| 128 | + | return nil, appport.ErrFaviconMiss | |
| 129 | + | } | |
| 130 | + | if err != nil { | |
| 131 | + | return nil, err | |
| 132 | + | } | |
| 133 | + | if len(data) == 0 { | |
| 134 | + | return nil, appport.ErrFaviconMiss | |
| 135 | + | } | |
| 136 | + | return data, nil | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func atomicWrite(path string, data []byte) error { | |
| 140 | + | if path == "" || len(data) == 0 { | |
| 141 | + | return appport.ErrFaviconInvalidInput | |
| 142 | + | } | |
| 143 | + | if err := os.MkdirAll(filepath.Dir(path), diskCacheDirPerm); err != nil { | |
| 144 | + | return err | |
| 145 | + | } | |
| 146 | + | tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*.tmp") | |
| 147 | + | if err != nil { | |
| 148 | + | return err | |
| 149 | + | } | |
| 150 | + | tmpName := tmp.Name() | |
| 151 | + | defer func() { _ = os.Remove(tmpName) }() | |
| 152 | + | if _, err := tmp.Write(data); err != nil { | |
| 153 | + | _ = tmp.Close() | |
| 154 | + | return err | |
| 155 | + | } | |
| 156 | + | if err := tmp.Chmod(diskCacheFilePerm); err != nil { | |
| 157 | + | _ = tmp.Close() | |
| 158 | + | return err | |
| 159 | + | } | |
| 160 | + | if err := tmp.Close(); err != nil { | |
| 161 | + | return err | |
| 162 | + | } | |
| 163 | + | if err := os.Rename(tmpName, path); err != nil { | |
| 164 | + | return fmt.Errorf("atomic write %s: %w", path, err) | |
| 165 | + | } | |
| 166 | + | return nil | |
| 167 | + | } | |
A internal/infrastructure/favicon/blob_store_test.go +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | package favicon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | appport "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | domainfavicon "github.com/bnema/dumber/internal/domain/favicon" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestBlobStoreWriteReadOverwriteAndInvalidate(t *testing.T) { | |
| 14 | + | ctx := context.Background() | |
| 15 | + | store := NewBlobStore(t.TempDir()) | |
| 16 | + | key := domainfavicon.Key("example.com") | |
| 17 | + | ||
| 18 | + | if err := store.WriteOriginal(ctx, key, []byte("ico1"), "image/x-icon"); err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | if err := store.WriteOriginal(ctx, key, []byte("ico2"), "image/png"); err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | got, ct, err := store.ReadOriginal(ctx, key) | |
| 25 | + | if err != nil { | |
| 26 | + | t.Fatal(err) | |
| 27 | + | } | |
| 28 | + | if string(got) != "ico2" || ct != "image/png" { | |
| 29 | + | t.Fatalf("original = %q %q", got, ct) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | if err = store.WritePNG(ctx, key, []byte("png")); err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | if err = store.WriteSizedPNG(ctx, key, 32, []byte("sized")); err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | if got, _, err = store.ReadPNG(ctx, key); err != nil || !bytes.Equal(got, []byte("png")) { | |
| 39 | + | t.Fatalf("png = %q %v", got, err) | |
| 40 | + | } | |
| 41 | + | if got, _, err = store.ReadSizedPNG(ctx, key, 32); err != nil || !bytes.Equal(got, []byte("sized")) { | |
| 42 | + | t.Fatalf("sized = %q %v", got, err) | |
| 43 | + | } | |
| 44 | + | if err = store.RemoveDerived(ctx, key); err != nil { | |
| 45 | + | t.Fatal(err) | |
| 46 | + | } | |
| 47 | + | _, _, err = store.ReadPNG(ctx, key) | |
| 48 | + | if !errors.Is(err, appport.ErrFaviconMiss) { | |
| 49 | + | t.Fatalf("ReadPNG err = %v", err) | |
| 50 | + | } | |
| 51 | + | _, _, err = store.ReadSizedPNG(ctx, key, 32) | |
| 52 | + | if !errors.Is(err, appport.ErrFaviconMiss) { | |
| 53 | + | t.Fatalf("ReadSizedPNG err = %v", err) | |
| 54 | + | } | |
| 55 | + | if got, _, err = store.ReadOriginal(ctx, key); err != nil || string(got) != "ico2" { | |
| 56 | + | t.Fatalf("original after invalidate = %q %v", got, err) | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestBlobStoreRemoveDerivedNoopsWhenDiskCacheDisabled(t *testing.T) { | |
| 61 | + | store := NewBlobStore("") | |
| 62 | + | if err := store.RemoveDerived(context.Background(), "example.com"); err != nil { | |
| 63 | + | t.Fatal(err) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestBlobStoreRemoveDerivedDoesNotRemoveSimilarDomainKeys(t *testing.T) { | |
| 68 | + | ctx := context.Background() | |
| 69 | + | store := NewBlobStore(t.TempDir()) | |
| 70 | + | if err := store.WriteSizedPNG(ctx, "example.com", 32, []byte("example")); err != nil { | |
| 71 | + | t.Fatal(err) | |
| 72 | + | } | |
| 73 | + | if err := store.WritePNG(ctx, "example.com.au", []byte("other-png")); err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | if err := store.WriteSizedPNG(ctx, "example.com.au", 32, []byte("other-sized")); err != nil { | |
| 77 | + | t.Fatal(err) | |
| 78 | + | } | |
| 79 | + | if err := store.RemoveDerived(ctx, "example.com"); err != nil { | |
| 80 | + | t.Fatal(err) | |
| 81 | + | } | |
| 82 | + | if _, _, err := store.ReadSizedPNG(ctx, "example.com", 32); !errors.Is(err, appport.ErrFaviconMiss) { | |
| 83 | + | t.Fatalf("example.com sized err = %v", err) | |
| 84 | + | } | |
| 85 | + | if got, _, err := store.ReadPNG(ctx, "example.com.au"); err != nil || string(got) != "other-png" { | |
| 86 | + | t.Fatalf("example.com.au png = %q, %v", got, err) | |
| 87 | + | } | |
| 88 | + | if got, _, err := store.ReadSizedPNG(ctx, "example.com.au", 32); err != nil || string(got) != "other-sized" { | |
| 89 | + | t.Fatalf("example.com.au sized = %q, %v", got, err) | |
| 90 | + | } | |
| 91 | + | } | |
A internal/infrastructure/favicon/converter.go +261 -0
| @@ -0,0 +1,261 @@ | |||
| 1 | + | package favicon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/binary" | |
| 7 | + | "fmt" | |
| 8 | + | "image" | |
| 9 | + | "image/color" | |
| 10 | + | "image/png" | |
| 11 | + | "mime" | |
| 12 | + | "regexp" | |
| 13 | + | "strconv" | |
| 14 | + | "strings" | |
| 15 | + | ||
| 16 | + | appport "github.com/bnema/dumber/internal/application/port" | |
| 17 | + | "golang.org/x/image/draw" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // ImageConverter converts safe favicon image formats into normalized PNGs. | |
| 21 | + | type ImageConverter struct{} | |
| 22 | + | ||
| 23 | + | func NewImageConverter() *ImageConverter { return &ImageConverter{} } | |
| 24 | + | ||
| 25 | + | func (*ImageConverter) Convert(ctx context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) { | |
| 26 | + | _ = ctx | |
| 27 | + | if len(original) == 0 { | |
| 28 | + | return nil, fmt.Errorf("%w: empty image", appport.ErrFaviconMiss) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | img, err := decodeFaviconImage(original, normalizeContentType(contentType)) | |
| 32 | + | if err != nil { | |
| 33 | + | return nil, err | |
| 34 | + | } | |
| 35 | + | pngBytes, err := encodePNG(img) | |
| 36 | + | if err != nil { | |
| 37 | + | return nil, err | |
| 38 | + | } | |
| 39 | + | out := &appport.ConvertedFavicon{PNG: pngBytes, SizedPNG: make(map[int][]byte)} | |
| 40 | + | for _, size := range sizes { | |
| 41 | + | if size <= 0 { | |
| 42 | + | continue | |
| 43 | + | } | |
| 44 | + | resized, err := resizeToPNG(img, size) | |
| 45 | + | if err != nil { | |
| 46 | + | return nil, err | |
| 47 | + | } | |
| 48 | + | out.SizedPNG[size] = resized | |
| 49 | + | } | |
| 50 | + | return out, nil | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func decodeFaviconImage(data []byte, ct string) (image.Image, error) { | |
| 54 | + | switch ct { | |
| 55 | + | case "image/svg+xml": | |
| 56 | + | return renderSimpleSVG(data) | |
| 57 | + | case "image/x-icon", "image/vnd.microsoft.icon", "image/ico": | |
| 58 | + | img, err := decodeICO(data) | |
| 59 | + | if err != nil { | |
| 60 | + | return nil, err | |
| 61 | + | } | |
| 62 | + | return img, nil | |
| 63 | + | case "image/webp": | |
| 64 | + | return renderWEBPPlaceholder(data) | |
| 65 | + | } | |
| 66 | + | if ct != "" && ct != "image/png" && ct != "image/jpeg" && ct != "image/gif" && ct != "image/webp" { | |
| 67 | + | return nil, fmt.Errorf("%w: unsupported favicon content type %s", appport.ErrFaviconMiss, ct) | |
| 68 | + | } | |
| 69 | + | img, _, err := image.Decode(bytes.NewReader(data)) | |
| 70 | + | if err != nil { | |
| 71 | + | return nil, fmt.Errorf("%w: decode favicon: %w", appport.ErrFaviconMiss, err) | |
| 72 | + | } | |
| 73 | + | return img, nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func normalizeContentType(contentType string) string { | |
| 77 | + | ct, _, err := mime.ParseMediaType(strings.TrimSpace(strings.ToLower(contentType))) | |
| 78 | + | if err == nil { | |
| 79 | + | return ct | |
| 80 | + | } | |
| 81 | + | return strings.TrimSpace(strings.ToLower(contentType)) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func encodePNG(img image.Image) ([]byte, error) { | |
| 85 | + | var buf bytes.Buffer | |
| 86 | + | if err := png.Encode(&buf, img); err != nil { | |
| 87 | + | return nil, err | |
| 88 | + | } | |
| 89 | + | return buf.Bytes(), nil | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func resizeToPNG(src image.Image, size int) ([]byte, error) { | |
| 93 | + | crop := cropImage(src, centerSquare(src.Bounds())) | |
| 94 | + | dst := image.NewRGBA(image.Rect(0, 0, size, size)) | |
| 95 | + | draw.CatmullRom.Scale(dst, dst.Bounds(), crop, crop.Bounds(), draw.Over, nil) | |
| 96 | + | return encodePNG(dst) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func centerSquare(bounds image.Rectangle) image.Rectangle { | |
| 100 | + | w, h := bounds.Dx(), bounds.Dy() | |
| 101 | + | if w > h { | |
| 102 | + | o := (w - h) / 2 | |
| 103 | + | return image.Rect(bounds.Min.X+o, bounds.Min.Y, bounds.Min.X+o+h, bounds.Max.Y) | |
| 104 | + | } | |
| 105 | + | if h > w { | |
| 106 | + | o := (h - w) / 2 | |
| 107 | + | return image.Rect(bounds.Min.X, bounds.Min.Y+o, bounds.Max.X, bounds.Min.Y+o+w) | |
| 108 | + | } | |
| 109 | + | return bounds | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func decodeICO(data []byte) (image.Image, error) { | |
| 113 | + | if len(data) < 6 || binary.LittleEndian.Uint16(data[0:2]) != 0 || binary.LittleEndian.Uint16(data[2:4]) != 1 { | |
| 114 | + | return nil, fmt.Errorf("%w: malformed ico header", appport.ErrFaviconMiss) | |
| 115 | + | } | |
| 116 | + | count := int(binary.LittleEndian.Uint16(data[4:6])) | |
| 117 | + | if count <= 0 || len(data) < 6+count*16 { | |
| 118 | + | return nil, fmt.Errorf("%w: malformed ico directory", appport.ErrFaviconMiss) | |
| 119 | + | } | |
| 120 | + | var best []byte | |
| 121 | + | bestBitCount := 0 | |
| 122 | + | bestArea := -1 | |
| 123 | + | for i := 0; i < count; i++ { | |
| 124 | + | entry := data[6+i*16 : 6+(i+1)*16] | |
| 125 | + | w, h := int(entry[0]), int(entry[1]) | |
| 126 | + | if w == 0 { | |
| 127 | + | w = 256 | |
| 128 | + | } | |
| 129 | + | if h == 0 { | |
| 130 | + | h = 256 | |
| 131 | + | } | |
| 132 | + | size := int(binary.LittleEndian.Uint32(entry[8:12])) | |
| 133 | + | offset := int(binary.LittleEndian.Uint32(entry[12:16])) | |
| 134 | + | if size <= 0 || offset < 0 || offset+size > len(data) { | |
| 135 | + | continue | |
| 136 | + | } | |
| 137 | + | payload := data[offset : offset+size] | |
| 138 | + | area := w * h | |
| 139 | + | if area > bestArea { | |
| 140 | + | bestArea = area | |
| 141 | + | best = payload | |
| 142 | + | bestBitCount = int(binary.LittleEndian.Uint16(entry[6:8])) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | if len(best) == 0 { | |
| 146 | + | return nil, fmt.Errorf("%w: ico contains no image", appport.ErrFaviconMiss) | |
| 147 | + | } | |
| 148 | + | if bytes.HasPrefix(best, []byte("\x89PNG\r\n\x1a\n")) { | |
| 149 | + | img, err := png.Decode(bytes.NewReader(best)) | |
| 150 | + | if err != nil { | |
| 151 | + | return nil, fmt.Errorf("%w: decode ico png: %w", appport.ErrFaviconMiss, err) | |
| 152 | + | } | |
| 153 | + | return img, nil | |
| 154 | + | } | |
| 155 | + | img, err := decodeICODIB(best, bestBitCount) | |
| 156 | + | if err != nil { | |
| 157 | + | return nil, fmt.Errorf("%w: decode ico bitmap: %w", appport.ErrFaviconMiss, err) | |
| 158 | + | } | |
| 159 | + | return img, nil | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func decodeICODIB(data []byte, bitCountHint int) (image.Image, error) { | |
| 163 | + | const dibHeaderSize = 40 | |
| 164 | + | if len(data) < dibHeaderSize { | |
| 165 | + | return nil, fmt.Errorf("malformed dib header") | |
| 166 | + | } | |
| 167 | + | headerSize := int(binary.LittleEndian.Uint32(data[0:4])) | |
| 168 | + | if headerSize < dibHeaderSize || len(data) < headerSize { | |
| 169 | + | return nil, fmt.Errorf("unsupported dib header") | |
| 170 | + | } | |
| 171 | + | width := int(int32(binary.LittleEndian.Uint32(data[4:8]))) | |
| 172 | + | heightAll := int(int32(binary.LittleEndian.Uint32(data[8:12]))) | |
| 173 | + | planes := binary.LittleEndian.Uint16(data[12:14]) | |
| 174 | + | bitCount := int(binary.LittleEndian.Uint16(data[14:16])) | |
| 175 | + | compression := binary.LittleEndian.Uint32(data[16:20]) | |
| 176 | + | if bitCount == 0 { | |
| 177 | + | bitCount = bitCountHint | |
| 178 | + | } | |
| 179 | + | if width <= 0 || heightAll <= 0 || planes != 1 || compression != 0 { | |
| 180 | + | return nil, fmt.Errorf("unsupported dib metadata") | |
| 181 | + | } | |
| 182 | + | height := heightAll / 2 | |
| 183 | + | if height <= 0 { | |
| 184 | + | return nil, fmt.Errorf("invalid dib height") | |
| 185 | + | } | |
| 186 | + | bytesPerPixel := bitCount / 8 | |
| 187 | + | if bitCount != 24 && bitCount != 32 { | |
| 188 | + | return nil, fmt.Errorf("unsupported dib bit depth %d", bitCount) | |
| 189 | + | } | |
| 190 | + | stride := ((width*bitCount + 31) / 32) * 4 | |
| 191 | + | pixelOffset := headerSize | |
| 192 | + | if len(data) < pixelOffset+stride*height { | |
| 193 | + | return nil, fmt.Errorf("truncated dib pixels") | |
| 194 | + | } | |
| 195 | + | img := image.NewNRGBA(image.Rect(0, 0, width, height)) | |
| 196 | + | const opaqueAlpha = 255 | |
| 197 | + | for y := 0; y < height; y++ { | |
| 198 | + | srcY := height - 1 - y | |
| 199 | + | row := data[pixelOffset+srcY*stride:] | |
| 200 | + | for x := 0; x < width; x++ { | |
| 201 | + | i := x * bytesPerPixel | |
| 202 | + | b, g, r := row[i], row[i+1], row[i+2] | |
| 203 | + | a := byte(opaqueAlpha) | |
| 204 | + | if bitCount == 32 { | |
| 205 | + | a = row[i+3] | |
| 206 | + | } | |
| 207 | + | img.SetNRGBA(x, y, color.NRGBA{R: r, G: g, B: b, A: a}) | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | return img, nil | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | var svgSizeRe = regexp.MustCompile(`(?i)\b(width|height)=["']?(\d+)`) // intentionally small safe subset | |
| 214 | + | var svgFillRe = regexp.MustCompile(`(?i)\bfill=["']#?([0-9a-f]{6})`) | |
| 215 | + | ||
| 216 | + | func renderWEBPPlaceholder(data []byte) (image.Image, error) { | |
| 217 | + | if len(data) < 12 || !bytes.HasPrefix(data, []byte("RIFF")) || string(data[8:12]) != "WEBP" { | |
| 218 | + | return nil, fmt.Errorf("%w: malformed webp", appport.ErrFaviconMiss) | |
| 219 | + | } | |
| 220 | + | return solidImage(64, color.RGBA{R: 128, G: 128, B: 128, A: 255}), nil | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func renderSimpleSVG(data []byte) (image.Image, error) { | |
| 224 | + | trimmed := bytes.TrimSpace(data) | |
| 225 | + | if !bytes.Contains(trimmed, []byte("<svg")) { | |
| 226 | + | return nil, fmt.Errorf("%w: malformed svg", appport.ErrFaviconMiss) | |
| 227 | + | } | |
| 228 | + | size := 64 | |
| 229 | + | for _, m := range svgSizeRe.FindAllSubmatch(trimmed, -1) { | |
| 230 | + | if v, err := strconv.Atoi(string(m[2])); err == nil && v > 0 && v <= 1024 { | |
| 231 | + | size = v | |
| 232 | + | break | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | fill := color.RGBA{R: 128, G: 128, B: 128, A: 255} | |
| 236 | + | if m := svgFillRe.FindSubmatch(trimmed); len(m) == 2 { | |
| 237 | + | if parsed, ok := parseHexColor(string(m[1])); ok { | |
| 238 | + | fill = parsed | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | return solidImage(size, fill), nil | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func solidImage(size int, fill color.RGBA) image.Image { | |
| 245 | + | img := image.NewRGBA(image.Rect(0, 0, size, size)) | |
| 246 | + | for y := 0; y < size; y++ { | |
| 247 | + | for x := 0; x < size; x++ { | |
| 248 | + | img.SetRGBA(x, y, fill) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | return img | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func parseHexColor(hex string) (color.RGBA, bool) { | |
| 255 | + | v, err := strconv.ParseUint(hex, 16, 32) | |
| 256 | + | if err != nil { | |
| 257 | + | return color.RGBA{}, false | |
| 258 | + | } | |
| 259 | + | const greenShift = 8 | |
| 260 | + | return color.RGBA{R: uint8(v >> 16), G: uint8(v >> greenShift), B: uint8(v), A: 255}, true | |
| 261 | + | } | |
A internal/infrastructure/favicon/converter_test.go +137 -0
| @@ -0,0 +1,137 @@ | |||
| 1 | + | package favicon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "image" | |
| 8 | + | "image/color" | |
| 9 | + | "image/png" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | appport "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestConverterPNGNormalizesAndSizes(t *testing.T) { | |
| 16 | + | conv := NewImageConverter() | |
| 17 | + | out, err := conv.Convert(context.Background(), testPNG(t, 64, 48), "image/png", []int{32}) | |
| 18 | + | if err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | if _, decodeErr := png.Decode(bytes.NewReader(out.PNG)); decodeErr != nil { | |
| 22 | + | t.Fatalf("normalized png invalid: %v", decodeErr) | |
| 23 | + | } | |
| 24 | + | img, err := png.Decode(bytes.NewReader(out.SizedPNG[32])) | |
| 25 | + | if err != nil { | |
| 26 | + | t.Fatal(err) | |
| 27 | + | } | |
| 28 | + | if img.Bounds().Dx() != 32 || img.Bounds().Dy() != 32 { | |
| 29 | + | t.Fatalf("size = %v", img.Bounds()) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestConverterAcceptedFaviconFormatsProducePNGs(t *testing.T) { | |
| 34 | + | conv := NewImageConverter() | |
| 35 | + | for _, tc := range []struct { | |
| 36 | + | name, ct string | |
| 37 | + | data []byte | |
| 38 | + | }{ | |
| 39 | + | {"png", "image/png", testPNG(t, 16, 16)}, | |
| 40 | + | {"ico with png payload", "image/x-icon", testICOWithPNG(t, testPNG(t, 16, 16))}, | |
| 41 | + | {"ico with bitmap payload", "image/x-icon", testICOWithDIB(t)}, | |
| 42 | + | {"svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><rect width="16" height="16" fill="#ff0000"/></svg>`)}, | |
| 43 | + | {"webp placeholder", "image/webp", []byte("RIFF\x04\x00\x00\x00WEBPVP8 ")}, | |
| 44 | + | } { | |
| 45 | + | t.Run(tc.name, func(t *testing.T) { | |
| 46 | + | out, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32}) | |
| 47 | + | if err != nil { | |
| 48 | + | t.Fatal(err) | |
| 49 | + | } | |
| 50 | + | if _, decodeErr := png.Decode(bytes.NewReader(out.PNG)); decodeErr != nil { | |
| 51 | + | t.Fatalf("normalized png invalid: %v", decodeErr) | |
| 52 | + | } | |
| 53 | + | img, err := png.Decode(bytes.NewReader(out.SizedPNG[32])) | |
| 54 | + | if err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | if img.Bounds().Dx() != 32 || img.Bounds().Dy() != 32 { | |
| 58 | + | t.Fatalf("size = %v", img.Bounds()) | |
| 59 | + | } | |
| 60 | + | }) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestConverterMalformedInputsAreMisses(t *testing.T) { | |
| 65 | + | conv := NewImageConverter() | |
| 66 | + | for _, tc := range []struct { | |
| 67 | + | name, ct string | |
| 68 | + | data []byte | |
| 69 | + | }{ | |
| 70 | + | {"ico", "image/x-icon", []byte{0, 0, 1, 0}}, | |
| 71 | + | {"svg", "image/svg+xml", []byte(`not svg`)}, | |
| 72 | + | {"webp", "image/webp", []byte("not webp")}, | |
| 73 | + | {"malformed", "image/png", []byte("not an image")}, | |
| 74 | + | } { | |
| 75 | + | t.Run(tc.name, func(t *testing.T) { | |
| 76 | + | if _, err := conv.Convert(context.Background(), tc.data, tc.ct, []int{32}); !errors.Is(err, appport.ErrFaviconMiss) { | |
| 77 | + | t.Fatalf("err = %v", err) | |
| 78 | + | } | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func testICOWithDIB(t *testing.T) []byte { | |
| 84 | + | t.Helper() | |
| 85 | + | const width, height, bitCount = 2, 2, 32 | |
| 86 | + | var dib bytes.Buffer | |
| 87 | + | writeLE32 := func(v uint32) { dib.Write([]byte{byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)}) } | |
| 88 | + | writeLE16 := func(v uint16) { dib.Write([]byte{byte(v), byte(v >> 8)}) } | |
| 89 | + | writeLE32(40) // BITMAPINFOHEADER size | |
| 90 | + | writeLE32(width) // width | |
| 91 | + | writeLE32(height * 2) // xor + and mask height | |
| 92 | + | writeLE16(1) // planes | |
| 93 | + | writeLE16(bitCount) // bit count | |
| 94 | + | writeLE32(0) // BI_RGB | |
| 95 | + | writeLE32(width * height * 4) // image size | |
| 96 | + | writeLE32(0) | |
| 97 | + | writeLE32(0) // pels per meter | |
| 98 | + | writeLE32(0) | |
| 99 | + | writeLE32(0) // colors | |
| 100 | + | dib.Write([]byte{0, 0, 255, 255, 0, 255, 0, 255}) // bottom row BGRA | |
| 101 | + | dib.Write([]byte{255, 0, 0, 255, 0, 0, 0, 255}) // top row BGRA | |
| 102 | + | return testICOWithPayload(t, dib.Bytes(), bitCount) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func testICOWithPNG(t *testing.T, pngData []byte) []byte { | |
| 106 | + | t.Helper() | |
| 107 | + | return testICOWithPayload(t, pngData, 32) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func testICOWithPayload(t *testing.T, payload []byte, bitCount byte) []byte { | |
| 111 | + | t.Helper() | |
| 112 | + | buf := bytes.NewBuffer(nil) | |
| 113 | + | buf.Write([]byte{0, 0, 1, 0, 1, 0}) // reserved, type icon, count | |
| 114 | + | buf.Write([]byte{16, 16, 0, 0}) // width, height, colors, reserved | |
| 115 | + | buf.Write([]byte{1, 0, bitCount, 0}) // planes, bit count | |
| 116 | + | size := uint32(len(payload)) | |
| 117 | + | offset := uint32(6 + 16) | |
| 118 | + | buf.Write([]byte{byte(size), byte(size >> 8), byte(size >> 16), byte(size >> 24)}) | |
| 119 | + | buf.Write([]byte{byte(offset), byte(offset >> 8), byte(offset >> 16), byte(offset >> 24)}) | |
| 120 | + | buf.Write(payload) | |
| 121 | + | return buf.Bytes() | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func testPNG(t *testing.T, w, h int) []byte { | |
| 125 | + | t.Helper() | |
| 126 | + | img := image.NewRGBA(image.Rect(0, 0, w, h)) | |
| 127 | + | for y := 0; y < h; y++ { | |
| 128 | + | for x := 0; x < w; x++ { | |
| 129 | + | img.Set(x, y, color.RGBA{R: 200, A: 255}) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | var buf bytes.Buffer | |
| 133 | + | if err := png.Encode(&buf, img); err != nil { | |
| 134 | + | t.Fatal(err) | |
| 135 | + | } | |
| 136 | + | return buf.Bytes() | |
| 137 | + | } | |
M internal/infrastructure/favicon/fetcher.go +246 -38
| @@ -2,82 +2,290 @@ | |||
| 2 | 2 | package favicon | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | + | "bytes" | |
| 5 | 6 | "context" | |
| 7 | + | "errors" | |
| 6 | 8 | "fmt" | |
| 7 | 9 | "io" | |
| 10 | + | "net" | |
| 8 | 11 | "net/http" | |
| 12 | + | "net/netip" | |
| 9 | 13 | "net/url" | |
| 14 | + | "strings" | |
| 10 | 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 | 21 | const ( | |
| 16 | - | // DuckDuckGo favicon API URL template. | |
| 17 | 22 | duckduckgoFaviconURL = "https://icons.duckduckgo.com/ip3/%s.ico" | |
| 18 | - | // HTTP client timeout for favicon fetch. | |
| 19 | - | fetchTimeout = 5 * time.Second | |
| 23 | + | fetchTimeout = 5 * time.Second | |
| 24 | + | maxFaviconBytes = 1 << 20 | |
| 25 | + | maxFaviconRedirects = 3 | |
| 26 | + | sniffLen = 512 | |
| 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 | 33 | type Fetcher struct { | |
| 24 | - | client *http.Client | |
| 34 | + | 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 | 39 | func NewFetcher() *Fetcher { | |
| 29 | - | return &Fetcher{ | |
| 30 | - | client: &http.Client{ | |
| 31 | - | Timeout: fetchTimeout, | |
| 32 | - | }, | |
| 33 | - | } | |
| 40 | + | f := &Fetcher{duckDuckGoURL: duckduckgoFaviconURL, maxBytes: maxFaviconBytes} | |
| 41 | + | transport := http.DefaultTransport.(*http.Transport).Clone() | |
| 42 | + | transport.DialContext = f.safeDialContext | |
| 43 | + | f.client = &http.Client{Timeout: fetchTimeout, Transport: transport, CheckRedirect: f.checkRedirect} | |
| 44 | + | return f | |
| 34 | 45 | } | |
| 35 | 46 | ||
| 36 | - | // Fetch retrieves favicon bytes for a domain from DuckDuckGo's favicon API. | |
| 37 | - | // Returns nil bytes if the favicon cannot be fetched. | |
| 38 | - | func (f *Fetcher) Fetch(ctx context.Context, domain string) ([]byte, error) { | |
| 39 | - | if domain == "" { | |
| 40 | - | return nil, nil | |
| 47 | + | func NewFetcherWithClient(client *http.Client, duckDuckGoURL string) *Fetcher { | |
| 48 | + | f := &Fetcher{client: client, duckDuckGoURL: duckDuckGoURL, maxBytes: maxFaviconBytes} | |
| 49 | + | if f.client == nil { | |
| 50 | + | f.client = &http.Client{Timeout: fetchTimeout} | |
| 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) | |
| 44 | - | faviconURL := fmt.Sprintf(duckduckgoFaviconURL, url.QueryEscape(domain)) | |
| 45 | - | ||
| 46 | - | log.Debug().Str("url", faviconURL).Msg("fetching favicon from DuckDuckGo") | |
| 60 | + | func (f *Fetcher) safeTransport(base http.RoundTripper) http.RoundTripper { | |
| 61 | + | transport, ok := base.(*http.Transport) | |
| 62 | + | if !ok || transport == nil { | |
| 63 | + | transport = http.DefaultTransport.(*http.Transport) | |
| 64 | + | } | |
| 65 | + | clone := transport.Clone() | |
| 66 | + | clone.DialContext = f.safeDialContext | |
| 67 | + | return clone | |
| 68 | + | } | |
| 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 | 72 | if err != nil { | |
| 50 | - | log.Debug().Err(err).Msg("failed to create favicon request") | |
| 51 | 73 | 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 | 91 | if err != nil { | |
| 56 | - | log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon from DuckDuckGo") | |
| 57 | 92 | return nil, err | |
| 58 | 93 | } | |
| 59 | - | defer func() { | |
| 60 | - | if closeErr := resp.Body.Close(); closeErr != nil { | |
| 61 | - | log.Debug().Err(closeErr).Str("domain", domain).Msg("failed to close favicon response body") | |
| 94 | + | return got.Bytes, nil | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (f *Fetcher) resolveURL(req appport.FaviconFetchRequest) (string, domainfavicon.Source, error) { | |
| 98 | + | if req.IconURL != "" { | |
| 99 | + | u, err := url.Parse(req.IconURL) | |
| 100 | + | if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" { | |
| 101 | + | return "", "", appport.ErrFaviconMiss | |
| 102 | + | } | |
| 103 | + | if !allowedURLForPage(req.PageURL, u) { | |
| 104 | + | return "", "", appport.ErrFaviconMiss | |
| 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 | 126 | if resp.StatusCode != http.StatusOK { | |
| 66 | - | log.Debug().Int("status", resp.StatusCode).Str("domain", domain).Msg("DuckDuckGo favicon API returned non-OK status") | |
| 67 | - | return nil, nil | |
| 127 | + | return nil, "", appport.ErrFaviconMiss | |
| 128 | + | } | |
| 129 | + | limit := f.maxBytes | |
| 130 | + | if limit <= 0 { | |
| 131 | + | limit = maxFaviconBytes | |
| 132 | + | } | |
| 133 | + | body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) | |
| 134 | + | if err != nil { | |
| 135 | + | return nil, "", err | |
| 136 | + | } | |
| 137 | + | if int64(len(body)) > limit { | |
| 138 | + | return nil, "", fmt.Errorf("favicon response exceeds %d bytes", limit) | |
| 139 | + | } | |
| 140 | + | if len(body) == 0 { | |
| 141 | + | return nil, "", appport.ErrFaviconMiss | |
| 142 | + | } | |
| 143 | + | declared := normalizeContentType(resp.Header.Get("Content-Type")) | |
| 144 | + | if declared == "" || declared == "application/octet-stream" { | |
| 145 | + | declared = normalizeContentType(http.DetectContentType(body[:min(len(body), sniffLen)])) | |
| 146 | + | } | |
| 147 | + | if !supportedFetchedContentType(declared) || !bodyMatchesContentType(declared, body) { | |
| 148 | + | return nil, "", appport.ErrFaviconMiss | |
| 149 | + | } | |
| 150 | + | return body, declared, nil | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func (*Fetcher) checkRedirect(req *http.Request, via []*http.Request) error { | |
| 154 | + | if len(via) >= maxFaviconRedirects { | |
| 155 | + | return http.ErrUseLastResponse | |
| 156 | + | } | |
| 157 | + | if req.URL.Scheme != "http" && req.URL.Scheme != "https" { | |
| 158 | + | return http.ErrUseLastResponse | |
| 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 | 173 | if err != nil { | |
| 72 | - | log.Debug().Err(err).Str("domain", domain).Msg("failed to read favicon response") | |
| 73 | 174 | 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 { | |
| 77 | - | log.Debug().Str("domain", domain).Msg("empty favicon response from DuckDuckGo") | |
| 78 | - | return nil, nil | |
| 192 | + | func supportedFetchedContentType(ct string) bool { | |
| 193 | + | switch ct { | |
| 194 | + | case "image/png", "image/x-icon", "image/vnd.microsoft.icon", "image/jpeg", "image/gif", "image/svg+xml", "image/webp": | |
| 195 | + | return true | |
| 196 | + | } | |
| 197 | + | return false | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func bodyMatchesContentType(ct string, body []byte) bool { | |
| 201 | + | switch ct { | |
| 202 | + | case "image/png": | |
| 203 | + | return bytes.HasPrefix(body, []byte("\x89PNG\r\n\x1a\n")) | |
| 204 | + | case "image/jpeg": | |
| 205 | + | return len(body) >= 3 && body[0] == 0xff && body[1] == 0xd8 && body[2] == 0xff | |
| 206 | + | case "image/gif": | |
| 207 | + | return bytes.HasPrefix(body, []byte("GIF87a")) || bytes.HasPrefix(body, []byte("GIF89a")) | |
| 208 | + | case "image/x-icon", "image/vnd.microsoft.icon": | |
| 209 | + | return len(body) >= 4 && body[0] == 0 && body[1] == 0 && (body[2] == 1 || body[2] == 2) && body[3] == 0 | |
| 210 | + | case "image/webp": | |
| 211 | + | return len(body) >= 12 && bytes.HasPrefix(body, []byte("RIFF")) && string(body[8:12]) == "WEBP" | |
| 212 | + | case "image/svg+xml": | |
| 213 | + | trimmed := bytes.TrimSpace(body) | |
| 214 | + | return bytes.HasPrefix(trimmed, []byte("<svg")) || (bytes.HasPrefix(trimmed, []byte("<?xml")) && bytes.Contains(trimmed, []byte("<svg"))) | |
| 215 | + | default: | |
| 216 | + | return false | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func allowedURLForPage(pageURL string, iconURL *url.URL) bool { | |
| 221 | + | if !isInternalHost(iconURL.Hostname()) { | |
| 222 | + | return true | |
| 223 | + | } | |
| 224 | + | page, err := url.Parse(pageURL) | |
| 225 | + | if err != nil { | |
| 226 | + | return false | |
| 227 | + | } | |
| 228 | + | return isLocalhost(page.Hostname()) && sameOrigin(page, iconURL) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func allowedURLForOrigin(ctx context.Context, target *url.URL) bool { | |
| 232 | + | if !isInternalHost(target.Hostname()) { | |
| 233 | + | return true | |
| 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") | |
| 82 | - | return data, nil | |
| 289 | + | func isPrivateAddr(addr netip.Addr) bool { | |
| 290 | + | return addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() || addr.IsUnspecified() | |
| 83 | 291 | } | |
A internal/infrastructure/favicon/fetcher_test.go +119 -0
| @@ -0,0 +1,119 @@ | |||
| 1 | + | package favicon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "net/url" | |
| 10 | + | "testing" | |
| 11 | + | ||
| 12 | + | appport "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestFetcherExplicitIconURLAndDuckDuckGoFallback(t *testing.T) { | |
| 16 | + | png := testPNG(t, 8, 8) | |
| 17 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 18 | + | w.Header().Set("Content-Type", "image/png") | |
| 19 | + | _, _ = w.Write(png) | |
| 20 | + | })) | |
| 21 | + | defer srv.Close() | |
| 22 | + | fetcher := NewFetcherWithClient(srv.Client(), srv.URL+"/%s.ico") | |
| 23 | + | ||
| 24 | + | got, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: srv.URL + "/page", IconURL: srv.URL + "/icon.png"}) | |
| 25 | + | if err != nil || got.ContentType != "image/png" || len(got.Bytes) == 0 { | |
| 26 | + | t.Fatalf("explicit got=%+v err=%v", got, err) | |
| 27 | + | } | |
| 28 | + | got, err = fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com/page"}) | |
| 29 | + | if err != nil || got.Source == "" || len(got.Bytes) == 0 { | |
| 30 | + | t.Fatalf("fallback got=%+v err=%v", got, err) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestFetcherMissesAndGuards(t *testing.T) { | |
| 35 | + | fetcher := NewFetcher() | |
| 36 | + | if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com", IconURL: "file:///x"}); !errors.Is(err, appport.ErrFaviconMiss) { | |
| 37 | + | t.Fatalf("scheme err=%v", err) | |
| 38 | + | } | |
| 39 | + | if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: "https://example.com", IconURL: "http://127.0.0.1/icon.png"}); !errors.Is(err, appport.ErrFaviconMiss) { | |
| 40 | + | t.Fatalf("ssrf err=%v", err) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusNotFound) })) | |
| 44 | + | defer srv.Close() | |
| 45 | + | fetcher = NewFetcherWithClient(srv.Client(), "") | |
| 46 | + | if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: srv.URL, IconURL: srv.URL + "/missing"}); !errors.Is(err, appport.ErrFaviconMiss) { | |
| 47 | + | t.Fatalf("404 err=%v", err) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestFetcherRejectsMismatchedDeclaredContentType(t *testing.T) { | |
| 52 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 53 | + | w.Header().Set("Content-Type", "image/png") | |
| 54 | + | _, _ = w.Write([]byte("<html>not a png</html>")) | |
| 55 | + | })) | |
| 56 | + | defer srv.Close() | |
| 57 | + | fetcher := NewFetcherWithClient(srv.Client(), "") | |
| 58 | + | if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: srv.URL, IconURL: srv.URL + "/icon.png"}); !errors.Is(err, appport.ErrFaviconMiss) { | |
| 59 | + | t.Fatalf("mismatched content err=%v", err) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestFetcherLocalhostExceptionRequiresSameOrigin(t *testing.T) { | |
| 64 | + | page := mustParseURL(t, "http://localhost:3000/page") | |
| 65 | + | same := mustParseURL(t, "http://localhost:3000/favicon.ico") | |
| 66 | + | otherPort := mustParseURL(t, "http://localhost:8080/favicon.ico") | |
| 67 | + | otherScheme := mustParseURL(t, "https://localhost:3000/favicon.ico") | |
| 68 | + | defaultPortPage := mustParseURL(t, "http://localhost/page") | |
| 69 | + | defaultPortIcon := mustParseURL(t, "http://localhost:80/favicon.ico") | |
| 70 | + | if !allowedURLForPage(page.String(), same) { | |
| 71 | + | t.Fatal("same-origin localhost should be allowed") | |
| 72 | + | } | |
| 73 | + | if allowedURLForPage(page.String(), otherPort) { | |
| 74 | + | t.Fatal("different localhost port should be rejected") | |
| 75 | + | } | |
| 76 | + | if allowedURLForPage(page.String(), otherScheme) { | |
| 77 | + | t.Fatal("different localhost scheme should be rejected") | |
| 78 | + | } | |
| 79 | + | if !allowedURLForPage(defaultPortPage.String(), defaultPortIcon) { | |
| 80 | + | t.Fatal("default localhost port should normalize as same-origin") | |
| 81 | + | } | |
| 82 | + | if got := allowedLocalOrigin(defaultPortPage.String()); got != "localhost:80" { | |
| 83 | + | t.Fatalf("allowedLocalOrigin default port = %q, want localhost:80", got) | |
| 84 | + | } | |
| 85 | + | if !allowedURLForOrigin(context.WithValue(context.Background(), allowedLocalOriginKey, allowedLocalOrigin(defaultPortPage.String())), mustParseURL(t, "http://localhost/favicon.ico")) { | |
| 86 | + | t.Fatal("same-origin localhost redirect with implicit default port should be allowed") | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func mustParseURL(t *testing.T, raw string) *url.URL { | |
| 91 | + | t.Helper() | |
| 92 | + | u, err := url.Parse(raw) | |
| 93 | + | if err != nil { | |
| 94 | + | t.Fatal(err) | |
| 95 | + | } | |
| 96 | + | return u | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestFetcherMaxSizeAndRedirectLimit(t *testing.T) { | |
| 100 | + | big := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 101 | + | w.Header().Set("Content-Type", "image/png") | |
| 102 | + | _, _ = w.Write(make([]byte, 20)) | |
| 103 | + | })) | |
| 104 | + | defer big.Close() | |
| 105 | + | fetcher := NewFetcherWithClient(big.Client(), "") | |
| 106 | + | fetcher.maxBytes = 4 | |
| 107 | + | if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: big.URL, IconURL: big.URL}); err == nil { | |
| 108 | + | t.Fatal("expected max size error") | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 112 | + | http.Redirect(w, r, fmt.Sprintf("/r%s", r.URL.Path), http.StatusFound) | |
| 113 | + | })) | |
| 114 | + | defer redir.Close() | |
| 115 | + | fetcher = NewFetcherWithClient(redir.Client(), "") | |
| 116 | + | if _, err := fetcher.Fetch(context.Background(), appport.FaviconFetchRequest{PageURL: redir.URL, IconURL: redir.URL + "/r"}); !errors.Is(err, appport.ErrFaviconMiss) { | |
| 117 | + | t.Fatalf("redirect err=%v", err) | |
| 118 | + | } | |
| 119 | + | } | |
A internal/infrastructure/favicon/scheduler.go +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | package favicon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/favicon" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type RefreshScheduler struct { | |
| 11 | + | mu sync.Mutex | |
| 12 | + | running map[favicon.Key]struct{} | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func NewRefreshScheduler() *RefreshScheduler { | |
| 16 | + | return &RefreshScheduler{running: make(map[favicon.Key]struct{})} | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (s *RefreshScheduler) Schedule(ctx context.Context, key favicon.Key, work func(context.Context)) bool { | |
| 20 | + | if s == nil || key == "" || work == nil { | |
| 21 | + | return false | |
| 22 | + | } | |
| 23 | + | s.mu.Lock() | |
| 24 | + | if _, ok := s.running[key]; ok { | |
| 25 | + | s.mu.Unlock() | |
| 26 | + | return false | |
| 27 | + | } | |
| 28 | + | s.running[key] = struct{}{} | |
| 29 | + | s.mu.Unlock() | |
| 30 | + | ||
| 31 | + | go func() { | |
| 32 | + | defer func() { | |
| 33 | + | s.mu.Lock() | |
| 34 | + | delete(s.running, key) | |
| 35 | + | s.mu.Unlock() | |
| 36 | + | }() | |
| 37 | + | work(ctx) | |
| 38 | + | }() | |
| 39 | + | return true | |
| 40 | + | } | |
M internal/infrastructure/favicon/service.go +20 -69
| @@ -15,24 +15,27 @@ const InternalScheme = "dumb://" | |||
| 15 | 15 | // InternalDomain is the pseudo-domain used for caching internal page favicons. | |
| 16 | 16 | const InternalDomain = "dumb" | |
| 17 | 17 | ||
| 18 | - | // Service implements the domain FaviconService interface. | |
| 19 | - | // It coordinates between the cache and fetcher components. | |
| 18 | + | // Service is the legacy favicon cache adapter kept for callers that have not yet | |
| 19 | + | // been migrated to the application favicon usecase. It must not own refresh or | |
| 20 | + | // remote-fetch policy; new code should use the usecase plus BlobStore/Fetcher. | |
| 20 | 21 | type Service struct { | |
| 21 | - | cache *Cache | |
| 22 | - | fetcher *Fetcher | |
| 22 | + | cache *Cache | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | 25 | // NewService creates a new favicon service. | |
| 26 | 26 | // cacheDir is the directory for disk caching; empty string disables disk caching. | |
| 27 | 27 | func NewService(cacheDir string) *Service { | |
| 28 | - | return &Service{ | |
| 29 | - | cache: NewCache(cacheDir), | |
| 30 | - | fetcher: NewFetcher(), | |
| 28 | + | return &Service{cache: NewCache(cacheDir)} | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (s *Service) Cache() *Cache { | |
| 32 | + | if s == nil { | |
| 33 | + | return nil | |
| 31 | 34 | } | |
| 35 | + | return s.cache | |
| 32 | 36 | } | |
| 33 | 37 | ||
| 34 | - | // Get returns favicon bytes for a domain. | |
| 35 | - | // Checks memory cache, then disk cache, then fetches from external API. | |
| 38 | + | // Get returns favicon bytes for a domain from the legacy cache only. | |
| 36 | 39 | func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) { | |
| 37 | 40 | if domain == "" { | |
| 38 | 41 | return nil, nil | |
| @@ -46,7 +49,6 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) { | |||
| 46 | 49 | return assets.LogoSVG, nil | |
| 47 | 50 | } | |
| 48 | 51 | ||
| 49 | - | // Check cache first (memory + disk) | |
| 50 | 52 | if data, ok := s.cache.Get(ctx, domain); ok { | |
| 51 | 53 | log.Debug(). | |
| 52 | 54 | Str("domain", domain). | |
| @@ -54,24 +56,8 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) { | |||
| 54 | 56 | Msg("favicon: Service.Get cache hit") | |
| 55 | 57 | return data, nil | |
| 56 | 58 | } | |
| 57 | - | ||
| 58 | - | // Fetch from external API | |
| 59 | - | data, err := s.fetcher.Fetch(ctx, domain) | |
| 60 | - | if err != nil { | |
| 61 | - | log.Debug().Err(err).Str("domain", domain).Msg("favicon: Service.Get fetch error") | |
| 62 | - | return nil, err | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // Store in cache if we got data | |
| 66 | - | if len(data) > 0 { | |
| 67 | - | s.cache.Set(ctx, domain, data) | |
| 68 | - | } | |
| 69 | - | log.Debug(). | |
| 70 | - | Str("domain", domain). | |
| 71 | - | Int("bytes", len(data)). | |
| 72 | - | Msg("favicon: Service.Get end") | |
| 73 | - | ||
| 74 | - | return data, nil | |
| 59 | + | log.Debug().Str("domain", domain).Msg("favicon: Service.Get cache miss") | |
| 60 | + | return nil, nil | |
| 75 | 61 | } | |
| 76 | 62 | ||
| 77 | 63 | // IsInternalURL checks if a URL uses the internal dumb:// scheme. | |
| @@ -127,35 +113,10 @@ func (s *Service) Close() { | |||
| 127 | 113 | s.cache.Close() | |
| 128 | 114 | } | |
| 129 | 115 | ||
| 130 | - | // EnsureDiskCache fetches favicon from external API and saves to disk if not already cached. | |
| 131 | - | // This is used when we receive a texture from WebKit but want to ensure disk persistence. | |
| 132 | - | func (s *Service) EnsureDiskCache(ctx context.Context, domain string) { | |
| 133 | - | if domain == "" { | |
| 134 | - | return | |
| 135 | - | } | |
| 136 | - | log := logging.FromContext(ctx) | |
| 137 | - | log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache begin") | |
| 138 | - | ||
| 139 | - | // Already on disk? | |
| 140 | - | if s.cache.HasOnDisk(domain) { | |
| 141 | - | log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache already on disk") | |
| 142 | - | return | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | // Fetch and store | |
| 146 | - | data, err := s.fetcher.Fetch(ctx, domain) | |
| 147 | - | if err != nil { | |
| 148 | - | log.Debug().Err(err).Str("domain", domain).Msg("failed to fetch favicon for disk cache") | |
| 149 | - | return | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | if len(data) > 0 { | |
| 153 | - | s.cache.Set(ctx, domain, data) | |
| 154 | - | } | |
| 155 | - | log.Debug(). | |
| 156 | - | Str("domain", domain). | |
| 157 | - | Int("bytes", len(data)). | |
| 158 | - | Msg("favicon: EnsureDiskCache end") | |
| 116 | + | // EnsureDiskCache is a legacy no-op. Disk population is handled by explicit | |
| 117 | + | // texture/blob writes after EnsureCacheDir; remote refresh policy belongs to the application usecase. | |
| 118 | + | func (*Service) EnsureDiskCache(ctx context.Context, domain string) { | |
| 119 | + | logging.FromContext(ctx).Debug().Str("domain", domain).Msg("favicon: legacy EnsureDiskCache skipped") | |
| 159 | 120 | } | |
| 160 | 121 | ||
| 161 | 122 | // DiskPathPNGSized returns the filesystem path for a sized PNG favicon. | |
| @@ -168,8 +129,8 @@ func (s *Service) HasPNGSizedOnDisk(domain string, size int) bool { | |||
| 168 | 129 | return s.cache.HasPNGSizedOnDisk(domain, size) | |
| 169 | 130 | } | |
| 170 | 131 | ||
| 171 | - | // EnsureSizedPNG creates a resized PNG from the original if it doesn't exist. | |
| 172 | - | // This is used to generate normalized icons for dmenu/fuzzel. | |
| 132 | + | // EnsureSizedPNG creates or overwrites a resized PNG from the original PNG. | |
| 133 | + | // This is a legacy adapter method; new code should use the favicon usecase. | |
| 173 | 134 | func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) error { | |
| 174 | 135 | if domain == "" { | |
| 175 | 136 | return nil | |
| @@ -180,16 +141,6 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e | |||
| 180 | 141 | Int("size", size). | |
| 181 | 142 | Msg("favicon: EnsureSizedPNG begin") | |
| 182 | 143 | ||
| 183 | - | // Already exists? | |
| 184 | - | if s.HasPNGSizedOnDisk(domain, size) { | |
| 185 | - | log.Debug(). | |
| 186 | - | Str("domain", domain). | |
| 187 | - | Int("size", size). | |
| 188 | - | Msg("favicon: EnsureSizedPNG already exists") | |
| 189 | - | return nil | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | // Check source PNG exists | |
| 193 | 144 | srcPath := s.DiskPathPNG(domain) | |
| 194 | 145 | if srcPath == "" || !s.HasPNGOnDisk(domain) { | |
| 195 | 146 | return fmt.Errorf("source PNG not found for domain %s", domain) | |
A internal/infrastructure/persistence/sqlite/favicon_repo.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | package sqlite | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "database/sql" | |
| 6 | + | "errors" | |
| 7 | + | "sync" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/favicon" | |
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type faviconRepo struct { | |
| 16 | + | queries *sqlc.Queries | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func NewFaviconRepository(db *sql.DB) port.FaviconRepository { | |
| 20 | + | return &faviconRepo{queries: sqlc.New(db)} | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | type lazyFaviconRepository struct { | |
| 24 | + | provider port.DatabaseProvider | |
| 25 | + | repo port.FaviconRepository | |
| 26 | + | once sync.Once | |
| 27 | + | initErr error | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func NewLazyFaviconRepository(provider port.DatabaseProvider) port.FaviconRepository { | |
| 31 | + | return &lazyFaviconRepository{provider: provider} | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (r *lazyFaviconRepository) init(ctx context.Context) error { | |
| 35 | + | r.once.Do(func() { | |
| 36 | + | db, err := r.provider.DB(ctx) | |
| 37 | + | if err != nil { | |
| 38 | + | r.initErr = err | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | r.repo = NewFaviconRepository(db) | |
| 42 | + | }) | |
| 43 | + | return r.initErr | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (r *lazyFaviconRepository) Get(ctx context.Context, key favicon.Key) (*favicon.Metadata, error) { | |
| 47 | + | if err := r.init(ctx); err != nil { | |
| 48 | + | return nil, err | |
| 49 | + | } | |
| 50 | + | return r.repo.Get(ctx, key) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (r *lazyFaviconRepository) FindFirst(ctx context.Context, keys []favicon.Key) (*favicon.Metadata, error) { | |
| 54 | + | if err := r.init(ctx); err != nil { | |
| 55 | + | return nil, err | |
| 56 | + | } | |
| 57 | + | return r.repo.FindFirst(ctx, keys) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func (r *lazyFaviconRepository) Upsert(ctx context.Context, meta favicon.Metadata) error { | |
| 61 | + | if err := r.init(ctx); err != nil { | |
| 62 | + | return err | |
| 63 | + | } | |
| 64 | + | return r.repo.Upsert(ctx, meta) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (r *lazyFaviconRepository) UpdateLastChecked(ctx context.Context, key favicon.Key, contentHash string, checkedAt time.Time) error { | |
| 68 | + | if err := r.init(ctx); err != nil { | |
| 69 | + | return err | |
| 70 | + | } | |
| 71 | + | return r.repo.UpdateLastChecked(ctx, key, contentHash, checkedAt) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (r *lazyFaviconRepository) Delete(ctx context.Context, key favicon.Key) error { | |
| 75 | + | if err := r.init(ctx); err != nil { | |
| 76 | + | return err | |
| 77 | + | } | |
| 78 | + | return r.repo.Delete(ctx, key) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (r *faviconRepo) Get(ctx context.Context, key favicon.Key) (*favicon.Metadata, error) { | |
| 82 | + | row, err := r.queries.GetFavicon(ctx, string(key)) | |
| 83 | + | if err != nil { | |
| 84 | + | if errors.Is(err, sql.ErrNoRows) { | |
| 85 | + | return nil, nil | |
| 86 | + | } | |
| 87 | + | return nil, err | |
| 88 | + | } | |
| 89 | + | return faviconFromRow(row), nil | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (r *faviconRepo) FindFirst(ctx context.Context, keys []favicon.Key) (*favicon.Metadata, error) { | |
| 93 | + | for _, key := range keys { | |
| 94 | + | meta, err := r.Get(ctx, key) | |
| 95 | + | if err != nil { | |
| 96 | + | return nil, err | |
| 97 | + | } | |
| 98 | + | if meta != nil { | |
| 99 | + | return meta, nil | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | return nil, nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (r *faviconRepo) Upsert(ctx context.Context, meta favicon.Metadata) error { | |
| 106 | + | return r.queries.UpsertFavicon(ctx, sqlc.UpsertFaviconParams{ | |
| 107 | + | Key: string(meta.Key), | |
| 108 | + | SourceUrl: nullString(meta.SourceURL), | |
| 109 | + | PageUrl: nullString(meta.PageURL), | |
| 110 | + | Source: string(meta.Source), | |
| 111 | + | ContentHash: meta.ContentHash, | |
| 112 | + | ContentType: nullString(meta.ContentType), | |
| 113 | + | UpdatedAt: meta.UpdatedAt, | |
| 114 | + | LastCheckedAt: meta.LastCheckedAt, | |
| 115 | + | }) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (r *faviconRepo) UpdateLastChecked(ctx context.Context, key favicon.Key, contentHash string, checkedAt time.Time) error { | |
| 119 | + | return r.queries.UpdateFaviconLastChecked(ctx, sqlc.UpdateFaviconLastCheckedParams{ | |
| 120 | + | Key: string(key), | |
| 121 | + | ContentHash: contentHash, | |
| 122 | + | LastCheckedAt: checkedAt, | |
| 123 | + | }) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func (r *faviconRepo) Delete(ctx context.Context, key favicon.Key) error { | |
| 127 | + | return r.queries.DeleteFavicon(ctx, string(key)) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func faviconFromRow(row sqlc.Favicon) *favicon.Metadata { | |
| 131 | + | return &favicon.Metadata{ | |
| 132 | + | Key: favicon.Key(row.Key), | |
| 133 | + | SourceURL: row.SourceUrl.String, | |
| 134 | + | PageURL: row.PageUrl.String, | |
| 135 | + | Source: favicon.Source(row.Source), | |
| 136 | + | ContentHash: row.ContentHash, | |
| 137 | + | ContentType: row.ContentType.String, | |
| 138 | + | UpdatedAt: row.UpdatedAt, | |
| 139 | + | LastCheckedAt: row.LastCheckedAt, | |
| 140 | + | } | |
| 141 | + | } | |
A internal/infrastructure/persistence/sqlite/favicon_repo_test.go +130 -0
| @@ -0,0 +1,130 @@ | |||
| 1 | + | package sqlite_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/favicon" | |
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func faviconTestCtx() context.Context { | |
| 18 | + | logger := logging.NewFromConfigValues("debug", "console") | |
| 19 | + | return logging.WithContext(context.Background(), logger) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func newFaviconRepo(t *testing.T) (context.Context, port.FaviconRepository) { | |
| 23 | + | t.Helper() | |
| 24 | + | ctx := faviconTestCtx() | |
| 25 | + | db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db")) | |
| 26 | + | require.NoError(t, err) | |
| 27 | + | t.Cleanup(func() { require.NoError(t, db.Close()) }) | |
| 28 | + | return ctx, sqlite.NewFaviconRepository(db) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestFaviconRepositoryUpsertAndGet(t *testing.T) { | |
| 32 | + | ctx, repo := newFaviconRepo(t) | |
| 33 | + | updatedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) | |
| 34 | + | checkedAt := updatedAt.Add(time.Hour) | |
| 35 | + | ||
| 36 | + | meta := favicon.Metadata{ | |
| 37 | + | Key: favicon.Key("example.com"), | |
| 38 | + | SourceURL: "https://example.com/favicon.ico", | |
| 39 | + | PageURL: "https://example.com/page", | |
| 40 | + | Source: favicon.SourceEngine, | |
| 41 | + | ContentHash: "sha256:first", | |
| 42 | + | ContentType: "image/png", | |
| 43 | + | UpdatedAt: updatedAt, | |
| 44 | + | LastCheckedAt: checkedAt, | |
| 45 | + | } | |
| 46 | + | require.NoError(t, repo.Upsert(ctx, meta)) | |
| 47 | + | ||
| 48 | + | got, err := repo.Get(ctx, favicon.Key("example.com")) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | require.NotNil(t, got) | |
| 51 | + | assert.Equal(t, meta.Key, got.Key) | |
| 52 | + | assert.Equal(t, meta.SourceURL, got.SourceURL) | |
| 53 | + | assert.Equal(t, meta.PageURL, got.PageURL) | |
| 54 | + | assert.Equal(t, meta.Source, got.Source) | |
| 55 | + | assert.Equal(t, meta.ContentHash, got.ContentHash) | |
| 56 | + | assert.Equal(t, meta.ContentType, got.ContentType) | |
| 57 | + | assert.True(t, meta.UpdatedAt.Equal(got.UpdatedAt), "updated_at = %s", got.UpdatedAt) | |
| 58 | + | assert.True(t, meta.LastCheckedAt.Equal(got.LastCheckedAt), "last_checked_at = %s", got.LastCheckedAt) | |
| 59 | + | ||
| 60 | + | replacement := meta | |
| 61 | + | replacement.SourceURL = "https://cdn.example.com/icon.png" | |
| 62 | + | replacement.Source = favicon.SourcePageDiscovery | |
| 63 | + | replacement.ContentHash = "sha256:second" | |
| 64 | + | replacement.UpdatedAt = updatedAt.Add(2 * time.Hour) | |
| 65 | + | replacement.LastCheckedAt = checkedAt.Add(2 * time.Hour) | |
| 66 | + | require.NoError(t, repo.Upsert(ctx, replacement)) | |
| 67 | + | ||
| 68 | + | got, err = repo.Get(ctx, favicon.Key("example.com")) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | require.NotNil(t, got) | |
| 71 | + | assert.Equal(t, replacement.SourceURL, got.SourceURL) | |
| 72 | + | assert.Equal(t, replacement.Source, got.Source) | |
| 73 | + | assert.Equal(t, replacement.ContentHash, got.ContentHash) | |
| 74 | + | assert.True(t, replacement.UpdatedAt.Equal(got.UpdatedAt)) | |
| 75 | + | assert.True(t, replacement.LastCheckedAt.Equal(got.LastCheckedAt)) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestFaviconRepositoryGetNotFoundReturnsNil(t *testing.T) { | |
| 79 | + | ctx, repo := newFaviconRepo(t) | |
| 80 | + | ||
| 81 | + | got, err := repo.Get(ctx, favicon.Key("missing.example")) | |
| 82 | + | require.NoError(t, err) | |
| 83 | + | assert.Nil(t, got) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestFaviconRepositoryFindFirstUsesCallerCandidateOrder(t *testing.T) { | |
| 87 | + | ctx, repo := newFaviconRepo(t) | |
| 88 | + | now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) | |
| 89 | + | require.NoError(t, repo.Upsert(ctx, favicon.Metadata{Key: favicon.Key("example.com"), Source: favicon.SourceEngine, ContentHash: "parent", UpdatedAt: now, LastCheckedAt: now})) | |
| 90 | + | require.NoError(t, repo.Upsert(ctx, favicon.Metadata{Key: favicon.Key("docs.example.com"), Source: favicon.SourceEngine, ContentHash: "child", UpdatedAt: now, LastCheckedAt: now})) | |
| 91 | + | ||
| 92 | + | got, err := repo.FindFirst(ctx, []favicon.Key{favicon.Key("missing.example.com"), favicon.Key("example.com"), favicon.Key("docs.example.com")}) | |
| 93 | + | require.NoError(t, err) | |
| 94 | + | require.NotNil(t, got) | |
| 95 | + | assert.Equal(t, favicon.Key("example.com"), got.Key) | |
| 96 | + | assert.Equal(t, "parent", got.ContentHash) | |
| 97 | + | ||
| 98 | + | got, err = repo.FindFirst(ctx, nil) | |
| 99 | + | require.NoError(t, err) | |
| 100 | + | assert.Nil(t, got) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func TestFaviconRepositoryUpdateLastChecked(t *testing.T) { | |
| 104 | + | ctx, repo := newFaviconRepo(t) | |
| 105 | + | updatedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) | |
| 106 | + | checkedAt := updatedAt.Add(24 * time.Hour) | |
| 107 | + | meta := favicon.Metadata{Key: favicon.Key("example.com"), Source: favicon.SourceEngine, ContentHash: "old", UpdatedAt: updatedAt, LastCheckedAt: updatedAt} | |
| 108 | + | require.NoError(t, repo.Upsert(ctx, meta)) | |
| 109 | + | ||
| 110 | + | require.NoError(t, repo.UpdateLastChecked(ctx, favicon.Key("example.com"), "new", checkedAt)) | |
| 111 | + | ||
| 112 | + | got, err := repo.Get(ctx, favicon.Key("example.com")) | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | require.NotNil(t, got) | |
| 115 | + | assert.Equal(t, "new", got.ContentHash) | |
| 116 | + | assert.True(t, updatedAt.Equal(got.UpdatedAt), "updated_at should not change") | |
| 117 | + | assert.True(t, checkedAt.Equal(got.LastCheckedAt)) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestFaviconRepositoryDelete(t *testing.T) { | |
| 121 | + | ctx, repo := newFaviconRepo(t) | |
| 122 | + | now := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) | |
| 123 | + | require.NoError(t, repo.Upsert(ctx, favicon.Metadata{Key: favicon.Key("example.com"), Source: favicon.SourceEngine, ContentHash: "hash", UpdatedAt: now, LastCheckedAt: now})) | |
| 124 | + | ||
| 125 | + | require.NoError(t, repo.Delete(ctx, favicon.Key("example.com"))) | |
| 126 | + | ||
| 127 | + | got, err := repo.Get(ctx, favicon.Key("example.com")) | |
| 128 | + | require.NoError(t, err) | |
| 129 | + | assert.Nil(t, got) | |
| 130 | + | } | |
A internal/infrastructure/persistence/sqlite/migrations/009_favicons.sql +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | -- +goose Up | |
| 2 | + | -- Store favicon cache metadata. Binary favicon data remains on disk. | |
| 3 | + | ||
| 4 | + | CREATE TABLE IF NOT EXISTS favicons ( | |
| 5 | + | key TEXT PRIMARY KEY, | |
| 6 | + | source_url TEXT, | |
| 7 | + | page_url TEXT, | |
| 8 | + | source TEXT NOT NULL, | |
| 9 | + | content_hash TEXT NOT NULL, | |
| 10 | + | content_type TEXT, | |
| 11 | + | updated_at TIMESTAMP NOT NULL, | |
| 12 | + | last_checked_at TIMESTAMP NOT NULL | |
| 13 | + | ); | |
| 14 | + | ||
| 15 | + | -- +goose Down | |
| 16 | + | DROP TABLE IF EXISTS favicons; | |
A internal/infrastructure/persistence/sqlite/queries/favicon.sql +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | -- name: GetFavicon :one | |
| 2 | + | SELECT * FROM favicons WHERE key = ? LIMIT 1; | |
| 3 | + | ||
| 4 | + | -- name: UpsertFavicon :exec | |
| 5 | + | INSERT INTO favicons ( | |
| 6 | + | key, | |
| 7 | + | source_url, | |
| 8 | + | page_url, | |
| 9 | + | source, | |
| 10 | + | content_hash, | |
| 11 | + | content_type, | |
| 12 | + | updated_at, | |
| 13 | + | last_checked_at | |
| 14 | + | ) | |
| 15 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?) | |
| 16 | + | ON CONFLICT(key) DO UPDATE SET | |
| 17 | + | source_url = EXCLUDED.source_url, | |
| 18 | + | page_url = EXCLUDED.page_url, | |
| 19 | + | source = EXCLUDED.source, | |
| 20 | + | content_hash = EXCLUDED.content_hash, | |
| 21 | + | content_type = EXCLUDED.content_type, | |
| 22 | + | updated_at = EXCLUDED.updated_at, | |
| 23 | + | last_checked_at = EXCLUDED.last_checked_at; | |
| 24 | + | ||
| 25 | + | -- name: UpdateFaviconLastChecked :exec | |
| 26 | + | UPDATE favicons | |
| 27 | + | SET content_hash = ?, last_checked_at = ? | |
| 28 | + | WHERE key = ?; | |
| 29 | + | ||
| 30 | + | -- name: DeleteFavicon :exec | |
| 31 | + | DELETE FROM favicons WHERE key = ?; | |
A internal/infrastructure/persistence/sqlite/sqlc/favicon.sql.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | // Code generated by sqlc. DO NOT EDIT. | |
| 2 | + | // versions: | |
| 3 | + | // sqlc v1.30.0 | |
| 4 | + | // source: favicon.sql | |
| 5 | + | ||
| 6 | + | package sqlc | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "context" | |
| 10 | + | "database/sql" | |
| 11 | + | "time" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const DeleteFavicon = `-- name: DeleteFavicon :exec | |
| 15 | + | DELETE FROM favicons WHERE key = ? | |
| 16 | + | ` | |
| 17 | + | ||
| 18 | + | func (q *Queries) DeleteFavicon(ctx context.Context, key string) error { | |
| 19 | + | _, err := q.db.ExecContext(ctx, DeleteFavicon, key) | |
| 20 | + | return err | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | const GetFavicon = `-- name: GetFavicon :one | |
| 24 | + | SELECT "key", source_url, page_url, source, content_hash, content_type, updated_at, last_checked_at FROM favicons WHERE key = ? LIMIT 1 | |
| 25 | + | ` | |
| 26 | + | ||
| 27 | + | func (q *Queries) GetFavicon(ctx context.Context, key string) (Favicon, error) { | |
| 28 | + | row := q.db.QueryRowContext(ctx, GetFavicon, key) | |
| 29 | + | var i Favicon | |
| 30 | + | err := row.Scan( | |
| 31 | + | &i.Key, | |
| 32 | + | &i.SourceUrl, | |
| 33 | + | &i.PageUrl, | |
| 34 | + | &i.Source, | |
| 35 | + | &i.ContentHash, | |
| 36 | + | &i.ContentType, | |
| 37 | + | &i.UpdatedAt, | |
| 38 | + | &i.LastCheckedAt, | |
| 39 | + | ) | |
| 40 | + | return i, err | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | const UpdateFaviconLastChecked = `-- name: UpdateFaviconLastChecked :exec | |
| 44 | + | UPDATE favicons | |
| 45 | + | SET content_hash = ?, last_checked_at = ? | |
| 46 | + | WHERE key = ? | |
| 47 | + | ` | |
| 48 | + | ||
| 49 | + | type UpdateFaviconLastCheckedParams struct { | |
| 50 | + | ContentHash string `json:"content_hash"` | |
| 51 | + | LastCheckedAt time.Time `json:"last_checked_at"` | |
| 52 | + | Key string `json:"key"` | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func (q *Queries) UpdateFaviconLastChecked(ctx context.Context, arg UpdateFaviconLastCheckedParams) error { | |
| 56 | + | _, err := q.db.ExecContext(ctx, UpdateFaviconLastChecked, arg.ContentHash, arg.LastCheckedAt, arg.Key) | |
| 57 | + | return err | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | const UpsertFavicon = `-- name: UpsertFavicon :exec | |
| 61 | + | INSERT INTO favicons ( | |
| 62 | + | key, | |
| 63 | + | source_url, | |
| 64 | + | page_url, | |
| 65 | + | source, | |
| 66 | + | content_hash, | |
| 67 | + | content_type, | |
| 68 | + | updated_at, | |
| 69 | + | last_checked_at | |
| 70 | + | ) | |
| 71 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?) | |
| 72 | + | ON CONFLICT(key) DO UPDATE SET | |
| 73 | + | source_url = EXCLUDED.source_url, | |
| 74 | + | page_url = EXCLUDED.page_url, | |
| 75 | + | source = EXCLUDED.source, | |
| 76 | + | content_hash = EXCLUDED.content_hash, | |
| 77 | + | content_type = EXCLUDED.content_type, | |
| 78 | + | updated_at = EXCLUDED.updated_at, | |
| 79 | + | last_checked_at = EXCLUDED.last_checked_at | |
| 80 | + | ` | |
| 81 | + | ||
| 82 | + | type UpsertFaviconParams struct { | |
| 83 | + | Key string `json:"key"` | |
| 84 | + | SourceUrl sql.NullString `json:"source_url"` | |
| 85 | + | PageUrl sql.NullString `json:"page_url"` | |
| 86 | + | Source string `json:"source"` | |
| 87 | + | ContentHash string `json:"content_hash"` | |
| 88 | + | ContentType sql.NullString `json:"content_type"` | |
| 89 | + | UpdatedAt time.Time `json:"updated_at"` | |
| 90 | + | LastCheckedAt time.Time `json:"last_checked_at"` | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (q *Queries) UpsertFavicon(ctx context.Context, arg UpsertFaviconParams) error { | |
| 94 | + | _, err := q.db.ExecContext(ctx, UpsertFavicon, | |
| 95 | + | arg.Key, | |
| 96 | + | arg.SourceUrl, | |
| 97 | + | arg.PageUrl, | |
| 98 | + | arg.Source, | |
| 99 | + | arg.ContentHash, | |
| 100 | + | arg.ContentType, | |
| 101 | + | arg.UpdatedAt, | |
| 102 | + | arg.LastCheckedAt, | |
| 103 | + | ) | |
| 104 | + | return err | |
| 105 | + | } | |
M internal/infrastructure/persistence/sqlite/sqlc/models.go +11 -0
| @@ -15,6 +15,17 @@ type ContentWhitelist struct { | |||
| 15 | 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 | 29 | type Favorite struct { | |
| 19 | 30 | ID int64 `json:"id"` | |
| 20 | 31 | Url string `json:"url"` | |
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +4 -0
| @@ -19,6 +19,7 @@ type Querier interface { | |||
| 19 | 19 | DeleteAllHistory(ctx context.Context) error | |
| 20 | 20 | // Deletes exited browser sessions older than the given cutoff time. | |
| 21 | 21 | DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error) | |
| 22 | + | DeleteFavicon(ctx context.Context, key string) error | |
| 22 | 23 | DeleteFavorite(ctx context.Context, id int64) error | |
| 23 | 24 | DeleteFolder(ctx context.Context, id int64) error | |
| 24 | 25 | DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error | |
| @@ -44,6 +45,7 @@ type Querier interface { | |||
| 44 | 45 | GetChildFolders(ctx context.Context, parentID sql.NullInt64) ([]FavoriteFolder, error) | |
| 45 | 46 | GetDailyVisitCount(ctx context.Context, date interface{}) ([]GetDailyVisitCountRow, error) | |
| 46 | 47 | GetDomainStats(ctx context.Context, limit int64) ([]GetDomainStatsRow, error) | |
| 48 | + | GetFavicon(ctx context.Context, key string) (Favicon, error) | |
| 47 | 49 | GetFavoriteByID(ctx context.Context, id int64) (Favorite, error) | |
| 48 | 50 | GetFavoriteByShortcut(ctx context.Context, shortcutKey sql.NullInt64) (Favorite, error) | |
| 49 | 51 | GetFavoriteByURL(ctx context.Context, url string) (Favorite, error) | |
| @@ -90,11 +92,13 @@ type Querier interface { | |||
| 90 | 92 | SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error | |
| 91 | 93 | SetPermission(ctx context.Context, arg SetPermissionParams) error | |
| 92 | 94 | SetZoomLevel(ctx context.Context, arg SetZoomLevelParams) error | |
| 95 | + | UpdateFaviconLastChecked(ctx context.Context, arg UpdateFaviconLastCheckedParams) error | |
| 93 | 96 | UpdateFavorite(ctx context.Context, arg UpdateFavoriteParams) error | |
| 94 | 97 | UpdateFavoritePosition(ctx context.Context, arg UpdateFavoritePositionParams) error | |
| 95 | 98 | UpdateFolder(ctx context.Context, arg UpdateFolderParams) error | |
| 96 | 99 | UpdateFolderPosition(ctx context.Context, arg UpdateFolderPositionParams) error | |
| 97 | 100 | UpdateTag(ctx context.Context, arg UpdateTagParams) error | |
| 101 | + | UpsertFavicon(ctx context.Context, arg UpsertFaviconParams) error | |
| 98 | 102 | UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error | |
| 99 | 103 | 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 | 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 via | |
| 8 | - | // dumb://api/favicon without triggering remote favicon fetches during History rendering. | |
| 9 | - | func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) { | |
| 7 | + | // SetSystemviewFaviconResolver exposes the application favicon resolver to | |
| 8 | + | // systemview pages via dumb://api/favicon without disk-service access. | |
| 9 | + | func (e *Engine) SetSystemviewFaviconResolver(resolver port.FaviconSystemviewResolver) { | |
| 10 | 10 | if e == nil || e.schemeHandler == nil { | |
| 11 | 11 | return | |
| 12 | 12 | } | |
| 13 | - | e.schemeHandler.SetFaviconService(service) | |
| 13 | + | e.schemeHandler.SetFaviconResolver(resolver) | |
| 14 | 14 | } | |
M internal/infrastructure/webkit/scheme_handler.go +13 -18
| @@ -9,7 +9,6 @@ import ( | |||
| 9 | 9 | "io/fs" | |
| 10 | 10 | "net/http" | |
| 11 | 11 | "net/url" | |
| 12 | - | "os" | |
| 13 | 12 | "path" | |
| 14 | 13 | "strconv" | |
| 15 | 14 | "strings" | |
| @@ -17,7 +16,6 @@ import ( | |||
| 17 | 16 | ||
| 18 | 17 | "github.com/andybalholm/brotli" | |
| 19 | 18 | "github.com/bnema/dumber/internal/application/port" | |
| 20 | - | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 21 | 19 | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 22 | 20 | "github.com/bnema/dumber/internal/logging" | |
| 23 | 21 | "github.com/bnema/puregotk/v4/gio" | |
| @@ -75,7 +73,7 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse { | |||
| 75 | 73 | type DumbSchemeHandler struct { | |
| 76 | 74 | handlers map[string]PageHandler | |
| 77 | 75 | assets embed.FS | |
| 78 | - | faviconService port.FaviconService | |
| 76 | + | faviconResolver port.FaviconSystemviewResolver | |
| 79 | 77 | assetDir string // default subdirectory within embed.FS (e.g., "systemviews") | |
| 80 | 78 | logger zerolog.Logger | |
| 81 | 79 | mu sync.RWMutex | |
| @@ -116,10 +114,10 @@ func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload fun | |||
| 116 | 114 | h.logger.Debug().Msg("config payload builders configured") | |
| 117 | 115 | } | |
| 118 | 116 | ||
| 119 | - | func (h *DumbSchemeHandler) SetFaviconService(service port.FaviconService) { | |
| 117 | + | func (h *DumbSchemeHandler) SetFaviconResolver(resolver port.FaviconSystemviewResolver) { | |
| 120 | 118 | h.mu.Lock() | |
| 121 | 119 | defer h.mu.Unlock() | |
| 122 | - | h.faviconService = service | |
| 120 | + | h.faviconResolver = resolver | |
| 123 | 121 | } | |
| 124 | 122 | ||
| 125 | 123 | // registerDefaults sets up default page handlers. | |
| @@ -210,9 +208,9 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse | |||
| 210 | 208 | return privateJSONErrorResponse(http.StatusForbidden, "forbidden") | |
| 211 | 209 | } | |
| 212 | 210 | h.mu.RLock() | |
| 213 | - | service := h.faviconService | |
| 211 | + | resolver := h.faviconResolver | |
| 214 | 212 | h.mu.RUnlock() | |
| 215 | - | if service == nil { | |
| 213 | + | if resolver == nil { | |
| 216 | 214 | return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable") | |
| 217 | 215 | } | |
| 218 | 216 | if req == nil { | |
| @@ -222,7 +220,7 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse | |||
| 222 | 220 | if err != nil { | |
| 223 | 221 | return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL") | |
| 224 | 222 | } | |
| 225 | - | domain := domainurl.CanonicalDomain(parsed.Query().Get("domain")) | |
| 223 | + | domain := strings.TrimSpace(parsed.Query().Get("domain")) | |
| 226 | 224 | if domain == "" { | |
| 227 | 225 | return privateJSONErrorResponse(http.StatusBadRequest, "missing domain") | |
| 228 | 226 | } | |
| @@ -235,20 +233,17 @@ func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse | |||
| 235 | 233 | size = parsedSize | |
| 236 | 234 | } | |
| 237 | 235 | ||
| 238 | - | if !service.HasPNGSizedOnDisk(domain, size) { | |
| 236 | + | resolved, err := resolver.ResolveSystemviewIcon(context.Background(), domain, size) | |
| 237 | + | if err != nil || resolved == nil || len(resolved.Bytes) == 0 { | |
| 239 | 238 | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |
| 240 | 239 | } | |
| 241 | - | diskPath := service.DiskPathPNGSized(domain, size) | |
| 242 | - | if diskPath == "" { | |
| 243 | - | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |
| 244 | - | } | |
| 245 | - | data, err := os.ReadFile(diskPath) | |
| 246 | - | if err != nil || len(data) == 0 { | |
| 247 | - | return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached") | |
| 240 | + | contentType := resolved.ContentType | |
| 241 | + | if contentType == "" { | |
| 242 | + | contentType = "image/png" | |
| 248 | 243 | } | |
| 249 | 244 | return &SchemeResponse{ | |
| 250 | - | Data: data, | |
| 251 | - | ContentType: "image/png", | |
| 245 | + | Data: resolved.Bytes, | |
| 246 | + | ContentType: contentType, | |
| 252 | 247 | StatusCode: http.StatusOK, | |
| 253 | 248 | Headers: map[string]string{"Cache-Control": "no-store"}, | |
| 254 | 249 | SuppressDefaultHeaders: true, | |
M internal/ui/adapter/favicon.go +161 -28
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/favicon" | |
| 11 | 12 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 12 | 13 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 14 | "github.com/bnema/puregotk/v4/gdk" | |
| @@ -19,6 +20,7 @@ import ( | |||
| 19 | 20 | // gdk.Texture conversion and WebKit FaviconDatabase integration. | |
| 20 | 21 | type FaviconAdapter struct { | |
| 21 | 22 | service port.FaviconService | |
| 23 | + | resolver port.FaviconResolver | |
| 22 | 24 | faviconDB port.FaviconDatabase | |
| 23 | 25 | textureCache map[string]*gdk.Texture | |
| 24 | 26 | mu sync.RWMutex | |
| @@ -57,9 +59,19 @@ type FaviconAdapterConfig struct { | |||
| 57 | 59 | // NewFaviconAdapter creates a new FaviconAdapter. | |
| 58 | 60 | // The faviconDB can be nil if a favicon database is not available. | |
| 59 | 61 | func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter { | |
| 62 | + | return NewFaviconAdapterWithResolver(service, nil, faviconDB, cfg) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func NewFaviconAdapterWithResolver( | |
| 66 | + | service port.FaviconService, | |
| 67 | + | resolver port.FaviconResolver, | |
| 68 | + | faviconDB port.FaviconDatabase, | |
| 69 | + | cfg FaviconAdapterConfig, | |
| 70 | + | ) *FaviconAdapter { | |
| 60 | 71 | disabled := os.Getenv(disableFaviconsEnv) == "1" | |
| 61 | 72 | return &FaviconAdapter{ | |
| 62 | 73 | service: service, | |
| 74 | + | resolver: resolver, | |
| 63 | 75 | faviconDB: faviconDB, | |
| 64 | 76 | textureCache: make(map[string]*gdk.Texture), | |
| 65 | 77 | warnCounts: make(map[string]int), | |
| @@ -132,26 +144,34 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 132 | 144 | return | |
| 133 | 145 | } | |
| 134 | 146 | ||
| 147 | + | // Check application resolver before falling back to the legacy service. | |
| 148 | + | if texture := a.resolveTexture(ctx, pageURL, true); texture != nil { | |
| 149 | + | callback(texture) | |
| 150 | + | return | |
| 151 | + | } | |
| 152 | + | ||
| 135 | 153 | // Check service cache (memory + disk) and convert to texture | |
| 136 | - | if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 { | |
| 137 | - | log.Debug(). | |
| 138 | - | Str("domain", domain). | |
| 139 | - | Int("bytes", len(data)). | |
| 140 | - | Msg("favicon: service cache hit") | |
| 141 | - | texture := a.textureFromBytesOnGTK(ctx, data) | |
| 142 | - | if texture != nil { | |
| 143 | - | a.setTexture(domain, texture) | |
| 144 | - | // Ensure sized PNG exists for CLI tools (async, idempotent) | |
| 145 | - | go a.ensureSizedPNG(ctx, domain) | |
| 146 | - | callback(texture) | |
| 147 | - | return | |
| 154 | + | if a.service != nil { | |
| 155 | + | if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 { | |
| 156 | + | log.Debug(). | |
| 157 | + | Str("domain", domain). | |
| 158 | + | Int("bytes", len(data)). | |
| 159 | + | Msg("favicon: service cache hit") | |
| 160 | + | texture := a.textureFromBytesOnGTK(ctx, data) | |
| 161 | + | if texture != nil { | |
| 162 | + | a.setTexture(domain, texture) | |
| 163 | + | // Ensure sized PNG exists for CLI tools (async, idempotent) | |
| 164 | + | go a.ensureSizedPNG(ctx, domain) | |
| 165 | + | callback(texture) | |
| 166 | + | return | |
| 167 | + | } | |
| 148 | 168 | } | |
| 149 | 169 | } | |
| 150 | 170 | ||
| 151 | 171 | // Try engine FaviconDatabase if available | |
| 152 | 172 | if a.faviconDB != nil { | |
| 153 | 173 | a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) { | |
| 154 | - | a.handleEngineFaviconDBTexture(ctx, domain, callback, tex) | |
| 174 | + | a.handleEngineFaviconDBTexture(ctx, pageURL, domain, callback, tex) | |
| 155 | 175 | }) | |
| 156 | 176 | return | |
| 157 | 177 | } | |
| @@ -162,6 +182,9 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 162 | 182 | } | |
| 163 | 183 | ||
| 164 | 184 | func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string, callback func(*gdk.Texture), texture *gdk.Texture) { | |
| 185 | + | if callback == nil { | |
| 186 | + | return | |
| 187 | + | } | |
| 165 | 188 | log := logging.FromContext(ctx) | |
| 166 | 189 | log.Debug(). | |
| 167 | 190 | Str("domain", domain). | |
| @@ -178,7 +201,49 @@ func (a *FaviconAdapter) invokeCallbackOnGTK(ctx context.Context, domain string, | |||
| 178 | 201 | glib.IdleAdd(&cb, 0) | |
| 179 | 202 | } | |
| 180 | 203 | ||
| 204 | + | func (a *FaviconAdapter) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string, callback func(*gdk.Texture)) { | |
| 205 | + | domain := domainurl.ExtractDomain(pageURL) | |
| 206 | + | if a.resolver == nil { | |
| 207 | + | a.invokeCallbackOnGTK(ctx, domain, callback, nil) | |
| 208 | + | return | |
| 209 | + | } | |
| 210 | + | go func() { | |
| 211 | + | if err := a.resolver.RefreshFromIconURLs(ctx, pageURL, iconURLs); err != nil { | |
| 212 | + | logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: RefreshFromIconURLs failed") | |
| 213 | + | } | |
| 214 | + | if callback == nil { | |
| 215 | + | return | |
| 216 | + | } | |
| 217 | + | a.GetOrFetch(ctx, pageURL, func(texture *gdk.Texture) { | |
| 218 | + | a.invokeCallbackOnGTK(ctx, domain, callback, texture) | |
| 219 | + | }) | |
| 220 | + | }() | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func (a *FaviconAdapter) resolveTexture(ctx context.Context, pageURL string, scheduleRefresh bool) *gdk.Texture { | |
| 224 | + | if a.resolver == nil { | |
| 225 | + | return nil | |
| 226 | + | } | |
| 227 | + | options := port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI} | |
| 228 | + | if scheduleRefresh { | |
| 229 | + | options.ScheduleBackgroundRefresh = true | |
| 230 | + | } | |
| 231 | + | resolved, err := a.resolver.Resolve(ctx, pageURL, 0, options) | |
| 232 | + | if err != nil || resolved == nil || len(resolved.Bytes) == 0 { | |
| 233 | + | return nil | |
| 234 | + | } | |
| 235 | + | texture := a.textureFromBytesOnGTK(ctx, resolved.Bytes) | |
| 236 | + | if texture != nil { | |
| 237 | + | a.setTexture(string(resolved.Key), texture) | |
| 238 | + | } | |
| 239 | + | return texture | |
| 240 | + | } | |
| 241 | + | ||
| 181 | 242 | func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, callback func(*gdk.Texture)) { | |
| 243 | + | if a.service == nil { | |
| 244 | + | a.invokeCallbackOnGTK(ctx, domain, callback, nil) | |
| 245 | + | return | |
| 246 | + | } | |
| 182 | 247 | go func() { | |
| 183 | 248 | log := logging.FromContext(ctx) | |
| 184 | 249 | log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin") | |
| @@ -209,7 +274,12 @@ func (a *FaviconAdapter) fetchViaService(ctx context.Context, domain string, cal | |||
| 209 | 274 | }() | |
| 210 | 275 | } | |
| 211 | 276 | ||
| 212 | - | func (a *FaviconAdapter) handleEngineFaviconDBTexture(ctx context.Context, domain string, callback func(*gdk.Texture), tex port.Texture) { | |
| 277 | + | func (a *FaviconAdapter) handleEngineFaviconDBTexture( | |
| 278 | + | ctx context.Context, | |
| 279 | + | pageURL, domain string, | |
| 280 | + | callback func(*gdk.Texture), | |
| 281 | + | tex port.Texture, | |
| 282 | + | ) { | |
| 213 | 283 | log := logging.FromContext(ctx) | |
| 214 | 284 | log.Debug(). | |
| 215 | 285 | Str("domain", domain). | |
| @@ -228,7 +298,7 @@ func (a *FaviconAdapter) handleEngineFaviconDBTexture(ctx context.Context, domai | |||
| 228 | 298 | } | |
| 229 | 299 | a.setTexture(domain, gdkTex) | |
| 230 | 300 | a.invokeCallbackOnGTK(ctx, domain, callback, gdkTex) | |
| 231 | - | a.saveFaviconToDisk(ctx, domain, gdkTex) | |
| 301 | + | a.storeTextureObservation(ctx, pageURL, gdkTex) | |
| 232 | 302 | } | |
| 233 | 303 | ||
| 234 | 304 | // StoreFromWebKit stores a favicon texture received from WebKit signals. | |
| @@ -252,7 +322,12 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te | |||
| 252 | 322 | // Store in texture cache | |
| 253 | 323 | a.setTexture(domain, texture) | |
| 254 | 324 | ||
| 255 | - | // Export as PNG and ensure disk cache | |
| 325 | + | if a.resolver != nil { | |
| 326 | + | a.storeTextureObservation(ctx, pageURL, texture) | |
| 327 | + | return | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // Legacy fallback for callers not yet wired to the application resolver. | |
| 256 | 331 | a.saveFaviconToDisk(ctx, domain, texture) | |
| 257 | 332 | } | |
| 258 | 333 | ||
| @@ -269,14 +344,55 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin( | |||
| 269 | 344 | currentDomain := domainurl.ExtractDomain(currentURL) | |
| 270 | 345 | if originDomain != "" && originDomain != currentDomain { | |
| 271 | 346 | a.setTexture(originDomain, texture) | |
| 272 | - | a.saveFaviconToDisk(ctx, originDomain, texture) | |
| 347 | + | if a.resolver != nil { | |
| 348 | + | a.storeTextureObservation(ctx, originURL, texture) | |
| 349 | + | } else { | |
| 350 | + | a.saveFaviconToDisk(ctx, originDomain, texture) | |
| 351 | + | } | |
| 273 | 352 | } | |
| 274 | 353 | } | |
| 275 | 354 | } | |
| 276 | 355 | ||
| 356 | + | func (a *FaviconAdapter) storeTextureObservation(ctx context.Context, pageURL string, texture *gdk.Texture) { | |
| 357 | + | if a.resolver == nil || texture == nil || pageURL == "" { | |
| 358 | + | return | |
| 359 | + | } | |
| 360 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 361 | + | tmp, err := os.CreateTemp("", "dumber-favicon-*.png") | |
| 362 | + | if err != nil { | |
| 363 | + | logging.FromContext(ctx).Debug().Err(err).Msg("favicon: failed to create temp PNG for observation") | |
| 364 | + | return false | |
| 365 | + | } | |
| 366 | + | path := tmp.Name() | |
| 367 | + | _ = tmp.Close() | |
| 368 | + | defer func() { _ = os.Remove(path) }() | |
| 369 | + | if ok := texture.SaveToPng(path); !ok { | |
| 370 | + | logging.FromContext(ctx).Debug().Str("page_url", pageURL).Msg("favicon: failed to export texture for observation") | |
| 371 | + | return false | |
| 372 | + | } | |
| 373 | + | data, err := os.ReadFile(path) | |
| 374 | + | if err != nil || len(data) == 0 { | |
| 375 | + | logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: failed to read exported observation") | |
| 376 | + | return false | |
| 377 | + | } | |
| 378 | + | go func() { | |
| 379 | + | // Engine-provided textures do not expose the original icon URL, so pageURL | |
| 380 | + | // is passed as the observation iconURL fallback for SourceEngine records. | |
| 381 | + | if _, err := a.resolver.Observe(ctx, pageURL, pageURL, data, favicon.SourceEngine, "image/png"); err != nil { | |
| 382 | + | logging.FromContext(ctx).Debug().Err(err).Str("page_url", pageURL).Msg("favicon: failed to observe engine texture") | |
| 383 | + | } | |
| 384 | + | }() | |
| 385 | + | return false | |
| 386 | + | }) | |
| 387 | + | glib.IdleAdd(&cb, 0) | |
| 388 | + | } | |
| 389 | + | ||
| 277 | 390 | // saveFaviconToDisk exports a favicon texture to PNG and ensures disk cache is populated. | |
| 278 | 391 | // Uses glib.IdleAdd to defer disk I/O until GTK main loop is idle, avoiding UI blocking. | |
| 279 | 392 | func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, texture *gdk.Texture) { | |
| 393 | + | if a.service == nil { | |
| 394 | + | return | |
| 395 | + | } | |
| 280 | 396 | log := logging.FromContext(ctx) | |
| 281 | 397 | cacheDirWarnKey := "cache-dir" | |
| 282 | 398 | savePNGWarnKey := "save-png:" + domain | |
| @@ -359,16 +475,23 @@ func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) * | |||
| 359 | 475 | return texture | |
| 360 | 476 | } | |
| 361 | 477 | ||
| 478 | + | // Check application resolver before falling back to the legacy service. | |
| 479 | + | if texture := a.resolveTexture(ctx, pageURL, false); texture != nil { | |
| 480 | + | return texture | |
| 481 | + | } | |
| 482 | + | ||
| 362 | 483 | // Check service cache (memory + disk) | |
| 363 | - | if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 { | |
| 364 | - | log.Debug(). | |
| 365 | - | Str("domain", domain). | |
| 366 | - | Int("bytes", len(data)). | |
| 367 | - | Msg("favicon: PreloadFromCache service hit") | |
| 368 | - | texture := a.textureFromBytesOnGTK(ctx, data) | |
| 369 | - | if texture != nil { | |
| 370 | - | a.setTexture(domain, texture) | |
| 371 | - | return texture | |
| 484 | + | if a.service != nil { | |
| 485 | + | if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 { | |
| 486 | + | log.Debug(). | |
| 487 | + | Str("domain", domain). | |
| 488 | + | Int("bytes", len(data)). | |
| 489 | + | Msg("favicon: PreloadFromCache service hit") | |
| 490 | + | texture := a.textureFromBytesOnGTK(ctx, data) | |
| 491 | + | if texture != nil { | |
| 492 | + | a.setTexture(domain, texture) | |
| 493 | + | return texture | |
| 494 | + | } | |
| 372 | 495 | } | |
| 373 | 496 | } | |
| 374 | 497 | ||
| @@ -384,7 +507,17 @@ func (a *FaviconAdapter) Service() port.FaviconService { | |||
| 384 | 507 | ||
| 385 | 508 | // Close shuts down the adapter and underlying service. | |
| 386 | 509 | func (a *FaviconAdapter) Close() { | |
| 387 | - | a.service.Close() | |
| 510 | + | if a.service != nil { | |
| 511 | + | a.service.Close() | |
| 512 | + | } | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | // Invalidate clears one resolved-key texture cache entry. | |
| 516 | + | func (a *FaviconAdapter) Invalidate(_ context.Context, key favicon.Key) error { | |
| 517 | + | a.mu.Lock() | |
| 518 | + | delete(a.textureCache, string(key)) | |
| 519 | + | a.mu.Unlock() | |
| 520 | + | return nil | |
| 388 | 521 | } | |
| 389 | 522 | ||
| 390 | 523 | // Clear removes all entries from the texture cache. | |
| @@ -415,7 +548,7 @@ func (a *FaviconAdapter) setTexture(domain string, texture *gdk.Texture) { | |||
| 415 | 548 | // ensureSizedPNG ensures the sized PNG exists for CLI tools. | |
| 416 | 549 | // This is idempotent - it only creates the file if it doesn't exist. | |
| 417 | 550 | func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) { | |
| 418 | - | if domain == "" { | |
| 551 | + | if domain == "" || a.service == nil { | |
| 419 | 552 | return | |
| 420 | 553 | } | |
| 421 | 554 | ||
M internal/ui/app.go +36 -12
| @@ -97,10 +97,10 @@ type App struct { | |||
| 97 | 97 | kbDispatcher *dispatcher.KeyboardDispatcher | |
| 98 | 98 | ||
| 99 | 99 | // Pane management (used by coordinators) | |
| 100 | - | panesUC *usecase.ManagePanesUseCase | |
| 101 | - | workspaceViews map[entity.TabID]*component.WorkspaceView | |
| 102 | - | windowForTab map[entity.TabID]*browserWindow | |
| 103 | - | widgetFactory layout.WidgetFactory | |
| 100 | + | panesUC *usecase.ManagePanesUseCase | |
| 101 | + | workspaceViews map[entity.TabID]*component.WorkspaceView | |
| 102 | + | windowForTab map[entity.TabID]*browserWindow | |
| 103 | + | widgetFactory layout.WidgetFactory | |
| 104 | 104 | workspaceViewCreateOverride func(context.Context, *entity.Tab) bool | |
| 105 | 105 | stackedPaneMgr *component.StackedPaneManager | |
| 106 | 106 | ||
| @@ -220,8 +220,14 @@ func New(deps *Dependencies) (*App, error) { | |||
| 220 | 220 | glib.IdleAdd(&cb, 0) | |
| 221 | 221 | }) | |
| 222 | 222 | } | |
| 223 | - | if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconServiceSetter); ok { | |
| 224 | - | faviconSetter.SetSystemviewFaviconService(deps.FaviconService) | |
| 223 | + | if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconResolverSetter); ok { | |
| 224 | + | resolver := deps.SystemviewFaviconResolver | |
| 225 | + | if resolver == nil { | |
| 226 | + | resolver = deps.FaviconResolver | |
| 227 | + | } | |
| 228 | + | if resolver != nil { | |
| 229 | + | faviconSetter.SetSystemviewFaviconResolver(resolver) | |
| 230 | + | } | |
| 225 | 231 | } | |
| 226 | 232 | ||
| 227 | 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 | 1361 | func NewStandaloneOmniboxRuntime( | |
| 1348 | 1362 | ctx context.Context, | |
| 1349 | 1363 | deps *Dependencies, | |
| 1350 | 1364 | faviconDB port.FaviconDatabase, | |
| 1351 | 1365 | ) *StandaloneOmniboxRuntime { | |
| 1352 | 1366 | var faviconAdapter *adapter.FaviconAdapter | |
| 1353 | - | if deps != nil && deps.FaviconService != nil { | |
| 1354 | - | faviconAdapter = adapter.NewFaviconAdapter(deps.FaviconService, faviconDB, deps.FaviconAdapterConfig) | |
| 1367 | + | if deps != nil && (deps.FaviconService != nil || deps.FaviconResolver != nil) { | |
| 1368 | + | faviconAdapter = adapter.NewFaviconAdapterWithResolver(deps.FaviconService, deps.FaviconResolver, faviconDB, deps.FaviconAdapterConfig) | |
| 1369 | + | registerFaviconInvalidator(deps.FaviconResolver, faviconAdapter) | |
| 1355 | 1370 | } | |
| 1356 | 1371 | ||
| 1357 | 1372 | omniboxCfg := buildOmniboxConfig(deps, faviconAdapter, omniboxCallbacks{ | |
| @@ -3136,10 +3151,19 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 3136 | 3151 | return a.activeWorkspace(), a.activeWorkspaceView() | |
| 3137 | 3152 | } | |
| 3138 | 3153 | ||
| 3139 | - | // Create FaviconAdapter with service and engine FaviconDatabase. | |
| 3140 | - | // Skip if FaviconService is nil (e.g. in tests or when favicon support is disabled). | |
| 3141 | - | if a.deps.FaviconService != nil { | |
| 3142 | - | a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, a.engine.FaviconDatabase(), a.deps.FaviconAdapterConfig) | |
| 3154 | + | // Create FaviconAdapter with resolver/service and engine FaviconDatabase. | |
| 3155 | + | // Skip if favicon support is not wired (e.g. in tests or when disabled). | |
| 3156 | + | if a.deps.FaviconService != nil || a.deps.FaviconResolver != nil { | |
| 3157 | + | a.faviconAdapter = adapter.NewFaviconAdapterWithResolver( | |
| 3158 | + | a.deps.FaviconService, | |
| 3159 | + | a.deps.FaviconResolver, | |
| 3160 | + | a.engine.FaviconDatabase(), | |
| 3161 | + | a.deps.FaviconAdapterConfig, | |
| 3162 | + | ) | |
| 3163 | + | registerFaviconInvalidator(a.deps.FaviconResolver, a.faviconAdapter) | |
| 3164 | + | if invalidator, ok := a.engine.(port.FaviconInvalidator); ok { | |
| 3165 | + | registerFaviconInvalidator(a.deps.FaviconResolver, invalidator) | |
| 3166 | + | } | |
| 3143 | 3167 | } | |
| 3144 | 3168 | ||
| 3145 | 3169 | // 1. Content Coordinator (no dependencies on other coordinators) | |
M internal/ui/coordinator/content/callbacks.go +1 -11
| @@ -9,7 +9,6 @@ import ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 12 | - | "github.com/bnema/puregotk/v4/gdk" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| 15 | 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 callbacks | |
| 126 | - | faviconGen := wv.Generation() | |
| 127 | - | callbacks.OnFaviconChanged = func(favicon port.Texture) { | |
| 128 | - | if wv.Generation() != faviconGen { | |
| 129 | - | return // WebView was reused, ignore stale signal | |
| 130 | - | } | |
| 131 | - | if gdkTexture, ok := favicon.(*gdk.Texture); ok { | |
| 132 | - | c.onFaviconChanged(ctx, paneID, wv, gdkTexture) | |
| 133 | - | } | |
| 134 | - | } | |
| 124 | + | c.setupFaviconCallbacks(ctx, paneID, wv, callbacks) | |
| 135 | 125 | ||
| 136 | 126 | // Middle-click link handler | |
| 137 | 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 | 59 | CopyURLUC *usecase.CopyURLUseCase | |
| 60 | 60 | ||
| 61 | 61 | // Infrastructure Adapters | |
| 62 | - | Clipboard port.Clipboard | |
| 63 | - | FaviconService port.FaviconService | |
| 64 | - | FaviconAdapterConfig adapter.FaviconAdapterConfig | |
| 65 | - | FilterManager port.FilterManager | |
| 66 | - | IdleInhibitor port.IdleInhibitor | |
| 62 | + | Clipboard port.Clipboard | |
| 63 | + | FaviconService port.FaviconService | |
| 64 | + | FaviconResolver port.FaviconResolver | |
| 65 | + | SystemviewFaviconResolver port.FaviconSystemviewResolver | |
| 66 | + | FaviconAdapterConfig adapter.FaviconAdapterConfig | |
| 67 | + | FilterManager port.FilterManager | |
| 68 | + | IdleInhibitor port.IdleInhibitor | |
| 67 | 69 | ||
| 68 | 70 | // Accent picker for dead keys support | |
| 69 | 71 | InsertAccentUC *usecase.InsertAccentUseCase | |