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