detached 2217286

feat(favicon): implement favicon support using gotk4-webkitgtk FaviconDatabase

Port favicon functionality to gotk4-webkitgtk bindings after discovering
that WebKitGTK 6.0 changed the favicon API from WebView signals to
FaviconDatabase accessed via WebsiteDataManager.

Changes:
- Connect to FaviconDatabase.favicon-changed signal in WebView initialization
- Enable favicon storage via WebsiteDataManager.SetFaviconsEnabled()
- Add dumb://favicon/ custom scheme handler to serve cached favicon files
- Fix favicon URL scheme typo (dumber:// → dumb://)
- Retrieve favicon data through NetworkSession → WebsiteDataManager → FaviconDatabase chain

This enables favicon display in:
- Homepage history list with proper fallback icons
- Stacked pane title bars
- Database storage with dumb://favicon/<hash>.png URLs

The implementation uses gotk4-webkitgtk native bindings without CGO,
following the WebKitGTK 6.0 API where favicons are managed globally
per NetworkSession rather than per WebView.
commit
2217286b9c0380ddec0217f20d909b948cd45b17
author
bamen · · 2025-10-15 05:09 UTC
parent
c358943
signature
unsigned
3 files · +97 -1
M internal/app/api/scheme.go +47 -0
@@ -6,6 +6,7 @@ import (
66 "log"
77 "mime"
88 neturl "net/url"
9+ "os"
910 "path/filepath"
1011 "strings"
1112
@@ -51,6 +52,7 @@ func (s *SchemeHandler) Handle(req *webkit.URISchemeRequest) {
5152 // Known forms:
5253 // - dumb://homepage or dumb:homepage → index.html
5354 // - dumb://app/index.html, dumb://app/<path> → serve from gui/<path>
55+ // - dumb://favicon/<hash>.png → serve cached favicon
5456 // - dumb://<anything> without path → index.html
5557 u, err := neturl.Parse(uri)
5658 if err != nil || u.Scheme != "dumb" {
@@ -58,6 +60,12 @@ func (s *SchemeHandler) Handle(req *webkit.URISchemeRequest) {
5860 return
5961 }
6062
63+ // Check if this is a favicon request
64+ if u.Host == "favicon" || (u.Host == "" && strings.HasPrefix(u.Path, "/favicon/")) {
65+ s.handleFavicon(req, u)
66+ return
67+ }
68+
6169 // Static assets
6270 s.handleAsset(req, u)
6371 }
@@ -183,3 +191,42 @@ func (s *SchemeHandler) getMimeType(filename string) string {
183191 return "text/plain"
184192 }
185193 }
194+
195+// handleFavicon serves cached favicon files
196+func (s *SchemeHandler) handleFavicon(req *webkit.URISchemeRequest, u *neturl.URL) {
197+ // Extract the filename from the URL
198+ // URL format: dumb://favicon/<hash>.png
199+ var filename string
200+ if u.Host == "favicon" {
201+ filename = strings.TrimPrefix(u.Path, "/")
202+ } else {
203+ filename = strings.TrimPrefix(u.Path, "/favicon/")
204+ }
205+
206+ if filename == "" {
207+ log.Printf("[scheme] favicon: empty filename")
208+ req.FinishError(fmt.Errorf("invalid favicon path"))
209+ return
210+ }
211+
212+ // Get the favicon cache directory path
213+ dataDir, err := config.GetDataDir()
214+ if err != nil {
215+ log.Printf("[scheme] favicon: failed to get data directory: %v", err)
216+ req.FinishError(fmt.Errorf("failed to get data directory"))
217+ return
218+ }
219+
220+ faviconPath := filepath.Join(dataDir, "favicons", filename)
221+
222+ // Read the favicon file
223+ data, err := os.ReadFile(faviconPath)
224+ if err != nil {
225+ log.Printf("[scheme] favicon: file not found: %s", faviconPath)
226+ req.FinishError(fmt.Errorf("favicon not found: %s", filename))
227+ return
228+ }
229+
230+ log.Printf("[scheme] favicon: serving %s (%d bytes)", filename, len(data))
231+ s.finishRequest(req, "image/png", data, filename)
232+}
@@ -6,6 +6,7 @@ import (
6 "log"6 "log"
7 "mime"7 "mime"
8 neturl "net/url"8 neturl "net/url"
9+ "os"
9 "path/filepath"10 "path/filepath"
10 "strings"11 "strings"
11 12
@@ -51,6 +52,7 @@ func (s *SchemeHandler) Handle(req *webkit.URISchemeRequest) {
51 // Known forms:52 // Known forms:
52 // - dumb://homepage or dumb:homepage → index.html53 // - dumb://homepage or dumb:homepage → index.html
53 // - dumb://app/index.html, dumb://app/<path> → serve from gui/<path>54 // - dumb://app/index.html, dumb://app/<path> → serve from gui/<path>
55+ // - dumb://favicon/<hash>.png → serve cached favicon
54 // - dumb://<anything> without path → index.html56 // - dumb://<anything> without path → index.html
55 u, err := neturl.Parse(uri)57 u, err := neturl.Parse(uri)
56 if err != nil || u.Scheme != "dumb" {58 if err != nil || u.Scheme != "dumb" {
@@ -58,6 +60,12 @@ func (s *SchemeHandler) Handle(req *webkit.URISchemeRequest) {
58 return60 return
59 }61 }
60 62
63+ // Check if this is a favicon request
64+ if u.Host == "favicon" || (u.Host == "" && strings.HasPrefix(u.Path, "/favicon/")) {
65+ s.handleFavicon(req, u)
66+ return
67+ }
68+
61 // Static assets69 // Static assets
62 s.handleAsset(req, u)70 s.handleAsset(req, u)
63 }71 }
@@ -183,3 +191,42 @@ func (s *SchemeHandler) getMimeType(filename string) string {
183 return "text/plain"191 return "text/plain"
184 }192 }
185 }193 }
194+
195+// handleFavicon serves cached favicon files
196+func (s *SchemeHandler) handleFavicon(req *webkit.URISchemeRequest, u *neturl.URL) {
197+ // Extract the filename from the URL
198+ // URL format: dumb://favicon/<hash>.png
199+ var filename string
200+ if u.Host == "favicon" {
201+ filename = strings.TrimPrefix(u.Path, "/")
202+ } else {
203+ filename = strings.TrimPrefix(u.Path, "/favicon/")
204+ }
205+
206+ if filename == "" {
207+ log.Printf("[scheme] favicon: empty filename")
208+ req.FinishError(fmt.Errorf("invalid favicon path"))
209+ return
210+ }
211+
212+ // Get the favicon cache directory path
213+ dataDir, err := config.GetDataDir()
214+ if err != nil {
215+ log.Printf("[scheme] favicon: failed to get data directory: %v", err)
216+ req.FinishError(fmt.Errorf("failed to get data directory"))
217+ return
218+ }
219+
220+ faviconPath := filepath.Join(dataDir, "favicons", filename)
221+
222+ // Read the favicon file
223+ data, err := os.ReadFile(faviconPath)
224+ if err != nil {
225+ log.Printf("[scheme] favicon: file not found: %s", faviconPath)
226+ req.FinishError(fmt.Errorf("favicon not found: %s", filename))
227+ return
228+ }
229+
230+ log.Printf("[scheme] favicon: serving %s (%d bytes)", filename, len(data))
231+ s.finishRequest(req, "image/png", data, filename)
232+}
M internal/services/browser.go +1 -1
@@ -606,7 +606,7 @@ func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) {
606606
607607 // Store a reference to the cached file in the database
608608 // We use a consistent path format for the favicon URL
609- faviconCacheURL := fmt.Sprintf("dumber://favicon/%x.png", md5.Sum([]byte(pageURL)))
609+ faviconCacheURL := fmt.Sprintf("dumb://favicon/%x.png", md5.Sum([]byte(pageURL)))
610610 faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true}
611611 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
612612 // Silently fail - favicon is not critical
@@ -606,7 +606,7 @@ func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) {
606 606
607 // Store a reference to the cached file in the database607 // Store a reference to the cached file in the database
608 // We use a consistent path format for the favicon URL608 // We use a consistent path format for the favicon URL
609- faviconCacheURL := fmt.Sprintf("dumber://favicon/%x.png", md5.Sum([]byte(pageURL)))609+ faviconCacheURL := fmt.Sprintf("dumb://favicon/%x.png", md5.Sum([]byte(pageURL)))
610 faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true}610 faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true}
611 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {611 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
612 // Silently fail - favicon is not critical612 // Silently fail - favicon is not critical
M pkg/webkit/webview.go +49 -0
@@ -201,6 +201,9 @@ func (w *WebView) setupEventHandlers() {
201201 }
202202 })
203203
204+ // Favicon changed - connect to FaviconDatabase
205+ w.setupFaviconHandlers()
206+
204207 // Load changed
205208 w.view.ConnectLoadChanged(func(event webkit.LoadEvent) {
206209 // Handle load events if needed
@@ -753,6 +756,52 @@ func (w *WebView) RegisterPaneModeHandler(handler func(action string) bool, isAc
753756 w.isPaneModeActive = isActiveChecker
754757 }
755758
759+// setupFaviconHandlers connects to the FaviconDatabase signals
760+func (w *WebView) setupFaviconHandlers() {
761+ // Get the NetworkSession from the WebView
762+ session := w.view.NetworkSession()
763+ if session == nil {
764+ log.Printf("[webkit] Warning: No NetworkSession available for favicon handling")
765+ return
766+ }
767+
768+ // Get the WebsiteDataManager from the NetworkSession
769+ dataManager := session.WebsiteDataManager()
770+ if dataManager == nil {
771+ log.Printf("[webkit] Warning: No WebsiteDataManager available for favicon handling")
772+ return
773+ }
774+
775+ // Enable favicons if not already enabled
776+ if !dataManager.FaviconsEnabled() {
777+ dataManager.SetFaviconsEnabled(true)
778+ log.Printf("[webkit] Enabled favicons for WebView ID %d", w.id)
779+ }
780+
781+ // Get the FaviconDatabase
782+ faviconDB := dataManager.FaviconDatabase()
783+ if faviconDB == nil {
784+ log.Printf("[webkit] Warning: No FaviconDatabase available")
785+ return
786+ }
787+
788+ // Connect to the favicon-changed signal
789+ faviconDB.ConnectFaviconChanged(func(pageURI, faviconURI string) {
790+ log.Printf("[favicon] Favicon changed for %s: %s", pageURI, faviconURI)
791+
792+ // Call the URI handler if registered
793+ w.mu.RLock()
794+ handler := w.onFaviconURIChanged
795+ w.mu.RUnlock()
796+
797+ if handler != nil {
798+ handler(pageURI, faviconURI)
799+ }
800+ })
801+
802+ log.Printf("[webkit] Connected to FaviconDatabase for WebView ID %d", w.id)
803+}
804+
756805 // GtkWebView returns the underlying gotk4 WebView for advanced operations
757806 // This is used when creating related views (popups) that need to share the same session
758807 func (w *WebView) GtkWebView() *webkit.WebView {
@@ -201,6 +201,9 @@ func (w *WebView) setupEventHandlers() {
201 }201 }
202 })202 })
203 203
204+ // Favicon changed - connect to FaviconDatabase
205+ w.setupFaviconHandlers()
206+
204 // Load changed207 // Load changed
205 w.view.ConnectLoadChanged(func(event webkit.LoadEvent) {208 w.view.ConnectLoadChanged(func(event webkit.LoadEvent) {
206 // Handle load events if needed209 // Handle load events if needed
@@ -753,6 +756,52 @@ func (w *WebView) RegisterPaneModeHandler(handler func(action string) bool, isAc
753 w.isPaneModeActive = isActiveChecker756 w.isPaneModeActive = isActiveChecker
754 }757 }
755 758
759+// setupFaviconHandlers connects to the FaviconDatabase signals
760+func (w *WebView) setupFaviconHandlers() {
761+ // Get the NetworkSession from the WebView
762+ session := w.view.NetworkSession()
763+ if session == nil {
764+ log.Printf("[webkit] Warning: No NetworkSession available for favicon handling")
765+ return
766+ }
767+
768+ // Get the WebsiteDataManager from the NetworkSession
769+ dataManager := session.WebsiteDataManager()
770+ if dataManager == nil {
771+ log.Printf("[webkit] Warning: No WebsiteDataManager available for favicon handling")
772+ return
773+ }
774+
775+ // Enable favicons if not already enabled
776+ if !dataManager.FaviconsEnabled() {
777+ dataManager.SetFaviconsEnabled(true)
778+ log.Printf("[webkit] Enabled favicons for WebView ID %d", w.id)
779+ }
780+
781+ // Get the FaviconDatabase
782+ faviconDB := dataManager.FaviconDatabase()
783+ if faviconDB == nil {
784+ log.Printf("[webkit] Warning: No FaviconDatabase available")
785+ return
786+ }
787+
788+ // Connect to the favicon-changed signal
789+ faviconDB.ConnectFaviconChanged(func(pageURI, faviconURI string) {
790+ log.Printf("[favicon] Favicon changed for %s: %s", pageURI, faviconURI)
791+
792+ // Call the URI handler if registered
793+ w.mu.RLock()
794+ handler := w.onFaviconURIChanged
795+ w.mu.RUnlock()
796+
797+ if handler != nil {
798+ handler(pageURI, faviconURI)
799+ }
800+ })
801+
802+ log.Printf("[webkit] Connected to FaviconDatabase for WebView ID %d", w.id)
803+}
804+
756 // GtkWebView returns the underlying gotk4 WebView for advanced operations805 // GtkWebView returns the underlying gotk4 WebView for advanced operations
757 // This is used when creating related views (popups) that need to share the same session806 // This is used when creating related views (popups) that need to share the same session
758 func (w *WebView) GtkWebView() *webkit.WebView {807 func (w *WebView) GtkWebView() *webkit.WebView {

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