detached 34fe48c

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
1414 //
1515 //go:embed logo.svg
1616 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.svg15 //go:embed logo.svg
16 var LogoSVG []byte16 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 (
1111 tea "github.com/charmbracelet/bubbletea"
1212 "github.com/spf13/cobra"
1313
14+ "github.com/bnema/dumber/assets"
1415 "github.com/bnema/dumber/internal/cli/model"
1516 domainurl "github.com/bnema/dumber/internal/domain/url"
1617 "github.com/bnema/dumber/internal/infrastructure/config"
18+ "github.com/bnema/dumber/internal/infrastructure/favicon"
1719 "github.com/bnema/dumber/internal/logging"
1820 )
1921
@@ -87,6 +89,13 @@ func getFaviconPath(rawURL string) string {
8789 return ""
8890 }
8991
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+
9099 domain := domainurl.ExtractDomain(rawURL)
91100 if domain == "" {
92101 return ""
@@ -94,7 +103,6 @@ func getFaviconPath(rawURL string) string {
94103
95104 ctx := app.Ctx()
96105 log := logging.FromContext(ctx)
97- const normalizedSize = 32
98106
99107 // Check if sized PNG already exists
100108 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 exists107 // 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) {
172172 }
173173 }
174174
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+
175202 // HasOnDisk checks if a favicon exists on disk for the given domain.
176203 func (c *Cache) HasOnDisk(domain string) bool {
177204 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
173173
174174 return ResizePNG(srcPath, dstPath, size)
175175 }
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 @@
2121 const getFaviconUrl = (fav: Favorite): string => {
2222 // Use provided favicon_url if available
2323 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+ }
2428 // Construct DuckDuckGo favicon URL from domain
2529 const domain = getDomain(fav.url);
2630 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 available22 // 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 domain28 // 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 @@
2323 const getFaviconUrl = (item: HistoryEntry): string => {
2424 // Use provided favicon_url if available
2525 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+ }
2630 // Construct DuckDuckGo favicon URL from domain
2731 const domain = getDomain(item.url).replace(/^www\./, '');
2832 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 available24 // 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 domain30 // 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`;

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close