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 ( | |||
| 6 | 6 | "log" | |
| 7 | 7 | "mime" | |
| 8 | 8 | neturl "net/url" | |
| 9 | + | "os" | |
| 9 | 10 | "path/filepath" | |
| 10 | 11 | "strings" | |
| 11 | 12 | ||
| @@ -51,6 +52,7 @@ func (s *SchemeHandler) Handle(req *webkit.URISchemeRequest) { | |||
| 51 | 52 | // Known forms: | |
| 52 | 53 | // - dumb://homepage or dumb:homepage → index.html | |
| 53 | 54 | // - dumb://app/index.html, dumb://app/<path> → serve from gui/<path> | |
| 55 | + | // - dumb://favicon/<hash>.png → serve cached favicon | |
| 54 | 56 | // - dumb://<anything> without path → index.html | |
| 55 | 57 | u, err := neturl.Parse(uri) | |
| 56 | 58 | if err != nil || u.Scheme != "dumb" { | |
| @@ -58,6 +60,12 @@ func (s *SchemeHandler) Handle(req *webkit.URISchemeRequest) { | |||
| 58 | 60 | 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 | 69 | // Static assets | |
| 62 | 70 | s.handleAsset(req, u) | |
| 63 | 71 | } | |
| @@ -183,3 +191,42 @@ func (s *SchemeHandler) getMimeType(filename string) string { | |||
| 183 | 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 | + | } | |
| @@ -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.html | 53 | // - 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.html | 56 | // - 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 | return | 60 | 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 assets | 69 | // 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) { | |||
| 606 | 606 | ||
| 607 | 607 | // Store a reference to the cached file in the database | |
| 608 | 608 | // 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 | 610 | faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true} | |
| 611 | 611 | if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil { | |
| 612 | 612 | // 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 database | 607 | // Store a reference to the cached file in the database | ||
| 608 | // We use a consistent path format for the favicon URL | 608 | // 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 critical | 612 | // Silently fail - favicon is not critical | ||
M pkg/webkit/webview.go +49 -0
| @@ -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 | 207 | // Load changed | |
| 205 | 208 | w.view.ConnectLoadChanged(func(event webkit.LoadEvent) { | |
| 206 | 209 | // Handle load events if needed | |
| @@ -753,6 +756,52 @@ func (w *WebView) RegisterPaneModeHandler(handler func(action string) bool, isAc | |||
| 753 | 756 | 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 | 805 | // GtkWebView returns the underlying gotk4 WebView for advanced operations | |
| 757 | 806 | // This is used when creating related views (popups) that need to share the same session | |
| 758 | 807 | 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 changed | 207 | // Load changed | ||
| 205 | w.view.ConnectLoadChanged(func(event webkit.LoadEvent) { | 208 | w.view.ConnectLoadChanged(func(event webkit.LoadEvent) { | ||
| 206 | // Handle load events if needed | 209 | // Handle load events if needed | ||
| @@ -753,6 +756,52 @@ func (w *WebView) RegisterPaneModeHandler(handler func(action string) bool, isAc | |||||
| 753 | w.isPaneModeActive = isActiveChecker | 756 | 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 operations | 805 | // GtkWebView returns the underlying gotk4 WebView for advanced operations | ||
| 757 | // This is used when creating related views (popups) that need to share the same session | 806 | // 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 { | ||