feat: add logo favicon support for dumb:// URLs in webui and dmenu
- commit
- 34fe48cd0e0019500d29f310ea4fa9333a58c9c9
- author
- brice · · 2025-12-28 16:34 UTC
- parent
- ba8a210
- signature
- unsigned
7 files · +61 -1
A assets/logo-32.png +0 -0 binary file: no text diff
binary file: no text diff
M assets/webui_embed.go +5 -0
| @@ -14,3 +14,8 @@ var WebUIAssets embed.FS | |||
| 14 | 14 | // | |
| 15 | 15 | //go:embed logo.svg | |
| 16 | 16 | var LogoSVG []byte | |
| 17 | + | ||
| 18 | + | // LogoPNG32 contains the dumber logo as 32x32 PNG for CLI tools like dmenu/rofi. | |
| 19 | + | // | |
| 20 | + | //go:embed logo-32.png | |
| 21 | + | var LogoPNG32 []byte | |
| @@ -14,3 +14,8 @@ var WebUIAssets embed.FS | |||||
| 14 | // | 14 | // | ||
| 15 | //go:embed logo.svg | 15 | //go:embed logo.svg | ||
| 16 | var LogoSVG []byte | 16 | var LogoSVG []byte | ||
| 17 | + | ||||
| 18 | + | // LogoPNG32 contains the dumber logo as 32x32 PNG for CLI tools like dmenu/rofi. | |||
| 19 | + | // | |||
| 20 | + | //go:embed logo-32.png | |||
| 21 | + | var LogoPNG32 []byte | |||
M internal/cli/cmd/dmenu.go +9 -1
| @@ -11,9 +11,11 @@ import ( | |||
| 11 | 11 | tea "github.com/charmbracelet/bubbletea" | |
| 12 | 12 | "github.com/spf13/cobra" | |
| 13 | 13 | ||
| 14 | + | "github.com/bnema/dumber/assets" | |
| 14 | 15 | "github.com/bnema/dumber/internal/cli/model" | |
| 15 | 16 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 18 | + | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 17 | 19 | "github.com/bnema/dumber/internal/logging" | |
| 18 | 20 | ) | |
| 19 | 21 | ||
| @@ -87,6 +89,13 @@ func getFaviconPath(rawURL string) string { | |||
| 87 | 89 | return "" | |
| 88 | 90 | } | |
| 89 | 91 | ||
| 92 | + | const normalizedSize = 32 | |
| 93 | + | ||
| 94 | + | // Handle internal dumb:// URLs - use embedded app logo | |
| 95 | + | if favicon.IsInternalURL(rawURL) { | |
| 96 | + | return app.FaviconService.EnsureInternalFaviconPNG(assets.LogoPNG32, normalizedSize) | |
| 97 | + | } | |
| 98 | + | ||
| 90 | 99 | domain := domainurl.ExtractDomain(rawURL) | |
| 91 | 100 | if domain == "" { | |
| 92 | 101 | return "" | |
| @@ -94,7 +103,6 @@ func getFaviconPath(rawURL string) string { | |||
| 94 | 103 | ||
| 95 | 104 | ctx := app.Ctx() | |
| 96 | 105 | log := logging.FromContext(ctx) | |
| 97 | - | const normalizedSize = 32 | |
| 98 | 106 | ||
| 99 | 107 | // Check if sized PNG already exists | |
| 100 | 108 | if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) { | |
| @@ -11,9 +11,11 @@ import ( | |||||
| 11 | tea "github.com/charmbracelet/bubbletea" | 11 | tea "github.com/charmbracelet/bubbletea" | ||
| 12 | "github.com/spf13/cobra" | 12 | "github.com/spf13/cobra" | ||
| 13 | 13 | ||||
| 14 | + | "github.com/bnema/dumber/assets" | |||
| 14 | "github.com/bnema/dumber/internal/cli/model" | 15 | "github.com/bnema/dumber/internal/cli/model" | ||
| 15 | domainurl "github.com/bnema/dumber/internal/domain/url" | 16 | domainurl "github.com/bnema/dumber/internal/domain/url" | ||
| 16 | "github.com/bnema/dumber/internal/infrastructure/config" | 17 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 18 | + | "github.com/bnema/dumber/internal/infrastructure/favicon" | |||
| 17 | "github.com/bnema/dumber/internal/logging" | 19 | "github.com/bnema/dumber/internal/logging" | ||
| 18 | ) | 20 | ) | ||
| 19 | 21 | ||||
| @@ -87,6 +89,13 @@ func getFaviconPath(rawURL string) string { | |||||
| 87 | return "" | 89 | return "" | ||
| 88 | } | 90 | } | ||
| 89 | 91 | ||||
| 92 | + | const normalizedSize = 32 | |||
| 93 | + | ||||
| 94 | + | // Handle internal dumb:// URLs - use embedded app logo | |||
| 95 | + | if favicon.IsInternalURL(rawURL) { | |||
| 96 | + | return app.FaviconService.EnsureInternalFaviconPNG(assets.LogoPNG32, normalizedSize) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 90 | domain := domainurl.ExtractDomain(rawURL) | 99 | domain := domainurl.ExtractDomain(rawURL) | ||
| 91 | if domain == "" { | 100 | if domain == "" { | ||
| 92 | return "" | 101 | return "" | ||
| @@ -94,7 +103,6 @@ func getFaviconPath(rawURL string) string { | |||||
| 94 | 103 | ||||
| 95 | ctx := app.Ctx() | 104 | ctx := app.Ctx() | ||
| 96 | log := logging.FromContext(ctx) | 105 | log := logging.FromContext(ctx) | ||
| 97 | - | const normalizedSize = 32 | |||
| 98 | 106 | ||||
| 99 | // Check if sized PNG already exists | 107 | // Check if sized PNG already exists | ||
| 100 | if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) { | 108 | if app.FaviconService.HasPNGSizedOnDisk(domain, normalizedSize) { | ||
M internal/infrastructure/favicon/cache.go +27 -0
| @@ -172,6 +172,33 @@ func (c *Cache) WritePNG(domain string, pngData []byte) { | |||
| 172 | 172 | } | |
| 173 | 173 | } | |
| 174 | 174 | ||
| 175 | + | // WritePNGSized writes raw PNG data to disk for a domain at a specific size. | |
| 176 | + | // Used for internal favicons that are pre-sized (e.g., embedded app logo). | |
| 177 | + | func (c *Cache) WritePNGSized(domain string, pngData []byte, size int) { | |
| 178 | + | if c.diskDir == "" || len(pngData) == 0 || domain == "" { | |
| 179 | + | return | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // Ensure directory exists | |
| 183 | + | if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil { | |
| 184 | + | return | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | filename := domainurl.SanitizeDomainForPNGSized(domain, size) | |
| 188 | + | finalPath := filepath.Join(c.diskDir, filename) | |
| 189 | + | tempPath := finalPath + ".tmp" | |
| 190 | + | ||
| 191 | + | // Write to temp file | |
| 192 | + | if err := os.WriteFile(tempPath, pngData, diskCacheFilePerm); err != nil { | |
| 193 | + | return | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // Atomic rename | |
| 197 | + | if err := os.Rename(tempPath, finalPath); err != nil { | |
| 198 | + | _ = os.Remove(tempPath) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 175 | 202 | // HasOnDisk checks if a favicon exists on disk for the given domain. | |
| 176 | 203 | func (c *Cache) HasOnDisk(domain string) bool { | |
| 177 | 204 | path := c.DiskPath(domain) | |
| @@ -172,6 +172,33 @@ func (c *Cache) WritePNG(domain string, pngData []byte) { | |||||
| 172 | } | 172 | } | ||
| 173 | } | 173 | } | ||
| 174 | 174 | ||||
| 175 | + | // WritePNGSized writes raw PNG data to disk for a domain at a specific size. | |||
| 176 | + | // Used for internal favicons that are pre-sized (e.g., embedded app logo). | |||
| 177 | + | func (c *Cache) WritePNGSized(domain string, pngData []byte, size int) { | |||
| 178 | + | if c.diskDir == "" || len(pngData) == 0 || domain == "" { | |||
| 179 | + | return | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | // Ensure directory exists | |||
| 183 | + | if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil { | |||
| 184 | + | return | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | filename := domainurl.SanitizeDomainForPNGSized(domain, size) | |||
| 188 | + | finalPath := filepath.Join(c.diskDir, filename) | |||
| 189 | + | tempPath := finalPath + ".tmp" | |||
| 190 | + | ||||
| 191 | + | // Write to temp file | |||
| 192 | + | if err := os.WriteFile(tempPath, pngData, diskCacheFilePerm); err != nil { | |||
| 193 | + | return | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // Atomic rename | |||
| 197 | + | if err := os.Rename(tempPath, finalPath); err != nil { | |||
| 198 | + | _ = os.Remove(tempPath) | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | ||||
| 175 | // HasOnDisk checks if a favicon exists on disk for the given domain. | 202 | // HasOnDisk checks if a favicon exists on disk for the given domain. | ||
| 176 | func (c *Cache) HasOnDisk(domain string) bool { | 203 | func (c *Cache) HasOnDisk(domain string) bool { | ||
| 177 | path := c.DiskPath(domain) | 204 | path := c.DiskPath(domain) | ||
M internal/infrastructure/favicon/service.go +12 -0
| @@ -173,3 +173,15 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e | |||
| 173 | 173 | ||
| 174 | 174 | return ResizePNG(srcPath, dstPath, size) | |
| 175 | 175 | } | |
| 176 | + | ||
| 177 | + | // EnsureInternalFaviconPNG ensures the internal app logo PNG exists on disk. | |
| 178 | + | // Returns the path to the sized PNG file. Used by dmenu to get a filesystem | |
| 179 | + | // path to the internal favicon for rofi/fuzzel display. | |
| 180 | + | func (s *Service) EnsureInternalFaviconPNG(pngData []byte, size int) string { | |
| 181 | + | if s.HasPNGSizedOnDisk(InternalDomain, size) { | |
| 182 | + | return s.DiskPathPNGSized(InternalDomain, size) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | s.cache.WritePNGSized(InternalDomain, pngData, size) | |
| 186 | + | return s.DiskPathPNGSized(InternalDomain, size) | |
| 187 | + | } | |
| @@ -173,3 +173,15 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e | |||||
| 173 | 173 | ||||
| 174 | return ResizePNG(srcPath, dstPath, size) | 174 | return ResizePNG(srcPath, dstPath, size) | ||
| 175 | } | 175 | } | ||
| 176 | + | ||||
| 177 | + | // EnsureInternalFaviconPNG ensures the internal app logo PNG exists on disk. | |||
| 178 | + | // Returns the path to the sized PNG file. Used by dmenu to get a filesystem | |||
| 179 | + | // path to the internal favicon for rofi/fuzzel display. | |||
| 180 | + | func (s *Service) EnsureInternalFaviconPNG(pngData []byte, size int) string { | |||
| 181 | + | if s.HasPNGSizedOnDisk(InternalDomain, size) { | |||
| 182 | + | return s.DiskPathPNGSized(InternalDomain, size) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | s.cache.WritePNGSized(InternalDomain, pngData, size) | |||
| 186 | + | return s.DiskPathPNGSized(InternalDomain, size) | |||
| 187 | + | } | |||
M webui/src/pages/homepage/favorites/FavoriteCard.svelte +4 -0
| @@ -21,6 +21,10 @@ | |||
| 21 | 21 | const getFaviconUrl = (fav: Favorite): string => { | |
| 22 | 22 | // Use provided favicon_url if available | |
| 23 | 23 | if (fav.favicon_url) return fav.favicon_url; | |
| 24 | + | // Handle internal dumb:// URLs - use embedded favicon | |
| 25 | + | if (fav.url.startsWith('dumb://')) { | |
| 26 | + | return 'favicon.png'; | |
| 27 | + | } | |
| 24 | 28 | // Construct DuckDuckGo favicon URL from domain | |
| 25 | 29 | const domain = getDomain(fav.url); | |
| 26 | 30 | return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`; | |
| @@ -21,6 +21,10 @@ | |||||
| 21 | const getFaviconUrl = (fav: Favorite): string => { | 21 | const getFaviconUrl = (fav: Favorite): string => { | ||
| 22 | // Use provided favicon_url if available | 22 | // Use provided favicon_url if available | ||
| 23 | if (fav.favicon_url) return fav.favicon_url; | 23 | if (fav.favicon_url) return fav.favicon_url; | ||
| 24 | + | // Handle internal dumb:// URLs - use embedded favicon | |||
| 25 | + | if (fav.url.startsWith('dumb://')) { | |||
| 26 | + | return 'favicon.png'; | |||
| 27 | + | } | |||
| 24 | // Construct DuckDuckGo favicon URL from domain | 28 | // Construct DuckDuckGo favicon URL from domain | ||
| 25 | const domain = getDomain(fav.url); | 29 | const domain = getDomain(fav.url); | ||
| 26 | return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`; | 30 | return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`; | ||
M webui/src/pages/homepage/history/HistoryItem.svelte +4 -0
| @@ -23,6 +23,10 @@ | |||
| 23 | 23 | const getFaviconUrl = (item: HistoryEntry): string => { | |
| 24 | 24 | // Use provided favicon_url if available | |
| 25 | 25 | if (item.favicon_url) return item.favicon_url; | |
| 26 | + | // Handle internal dumb:// URLs - use embedded favicon | |
| 27 | + | if (item.url.startsWith('dumb://')) { | |
| 28 | + | return 'favicon.png'; | |
| 29 | + | } | |
| 26 | 30 | // Construct DuckDuckGo favicon URL from domain | |
| 27 | 31 | const domain = getDomain(item.url).replace(/^www\./, ''); | |
| 28 | 32 | return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`; | |
| @@ -23,6 +23,10 @@ | |||||
| 23 | const getFaviconUrl = (item: HistoryEntry): string => { | 23 | const getFaviconUrl = (item: HistoryEntry): string => { | ||
| 24 | // Use provided favicon_url if available | 24 | // Use provided favicon_url if available | ||
| 25 | if (item.favicon_url) return item.favicon_url; | 25 | if (item.favicon_url) return item.favicon_url; | ||
| 26 | + | // Handle internal dumb:// URLs - use embedded favicon | |||
| 27 | + | if (item.url.startsWith('dumb://')) { | |||
| 28 | + | return 'favicon.png'; | |||
| 29 | + | } | |||
| 26 | // Construct DuckDuckGo favicon URL from domain | 30 | // Construct DuckDuckGo favicon URL from domain | ||
| 27 | const domain = getDomain(item.url).replace(/^www\./, ''); | 31 | const domain = getDomain(item.url).replace(/^www\./, ''); | ||
| 28 | return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`; | 32 | return `https://icons.duckduckgo.com/ip3/${encodeURIComponent(domain)}.ico`; | ||