Merge pull request #4 from bnema/refactor/favicon-webkit-native
refactor: favicon handling with WebKitGTK native integration
24 files · +841 -771
M CHANGELOG.md +15 -0
| @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. | |||
| 4 | 4 | ||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | + | ### Added | |
| 8 | + | - **Favicon refactoring**: WebKitGTK FaviconDatabase as single source of truth, removing fragile file-based cache and ico/bmp conversion dependencies | |
| 9 | + | - Native favicon detection and automatic PNG export at 32x32 for CLI (dmenu) | |
| 10 | + | - SVG favicon rasterization support | |
| 11 | + | - Async texture loading with callbacks for non-blocking UI updates | |
| 12 | + | - FaviconService handles favicon collection across all panes | |
| 13 | + | ||
| 14 | + | ### Changed | |
| 15 | + | - Removed old favicon cache system (go-ico, go-bmp dependencies) | |
| 16 | + | - Simplified omnibox favicon handling: uses database favicon_url only | |
| 17 | + | ||
| 18 | + | ### Fixed | |
| 19 | + | - Dark mode flash on startup: Set GTK dark theme preference and WebView background color before window/content creation | |
| 20 | + | - Cmd+Q and window close button now trigger graceful shutdown instead of killing the browser process | |
| 21 | + | ||
| 7 | 22 | ## [0.12.0] - 2025-10-16 | |
| 8 | 23 | ||
| 9 | 24 | ### Added | |
| @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. | |||||
| 4 | 4 | ||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | + | ### Added | |||
| 8 | + | - **Favicon refactoring**: WebKitGTK FaviconDatabase as single source of truth, removing fragile file-based cache and ico/bmp conversion dependencies | |||
| 9 | + | - Native favicon detection and automatic PNG export at 32x32 for CLI (dmenu) | |||
| 10 | + | - SVG favicon rasterization support | |||
| 11 | + | - Async texture loading with callbacks for non-blocking UI updates | |||
| 12 | + | - FaviconService handles favicon collection across all panes | |||
| 13 | + | ||||
| 14 | + | ### Changed | |||
| 15 | + | - Removed old favicon cache system (go-ico, go-bmp dependencies) | |||
| 16 | + | - Simplified omnibox favicon handling: uses database favicon_url only | |||
| 17 | + | ||||
| 18 | + | ### Fixed | |||
| 19 | + | - Dark mode flash on startup: Set GTK dark theme preference and WebView background color before window/content creation | |||
| 20 | + | - Cmd+Q and window close button now trigger graceful shutdown instead of killing the browser process | |||
| 21 | + | ||||
| 7 | ## [0.12.0] - 2025-10-16 | 22 | ## [0.12.0] - 2025-10-16 | ||
| 8 | 23 | ||||
| 9 | ### Added | 24 | ### Added | ||
M go.mod +8 -5
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | module github.com/bnema/dumber | |
| 2 | 2 | ||
| 3 | - | go 1.25.1 | |
| 3 | + | go 1.25.3 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/diamondburned/gotk4-webkitgtk/pkg v0.0.0-20240108031600-dee1973cf440 | |
| @@ -8,9 +8,9 @@ require ( | |||
| 8 | 8 | github.com/fsnotify/fsnotify v1.9.0 | |
| 9 | 9 | github.com/golang/mock v1.6.0 | |
| 10 | 10 | github.com/ncruces/go-sqlite3 v0.29.0 | |
| 11 | - | github.com/sergeymakinen/go-ico v1.0.0-beta.0 | |
| 12 | 11 | github.com/spf13/cobra v1.10.1 | |
| 13 | 12 | github.com/spf13/viper v1.21.0 | |
| 13 | + | golang.org/x/sys v0.36.0 | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | require ( | |
| @@ -22,7 +22,6 @@ require ( | |||
| 22 | 22 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | |
| 23 | 23 | github.com/rogpeppe/go-internal v1.12.0 // indirect | |
| 24 | 24 | github.com/sagikazarmark/locafero v0.11.0 // indirect | |
| 25 | - | github.com/sergeymakinen/go-bmp v1.0.0 // indirect | |
| 26 | 25 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | |
| 27 | 26 | github.com/spf13/afero v1.15.0 // indirect | |
| 28 | 27 | github.com/spf13/cast v1.10.0 // indirect | |
| @@ -32,9 +31,13 @@ require ( | |||
| 32 | 31 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 33 | 32 | go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect | |
| 34 | 33 | golang.org/x/sync v0.17.0 // indirect | |
| 35 | - | golang.org/x/sys v0.36.0 // indirect | |
| 36 | 34 | golang.org/x/text v0.29.0 // indirect | |
| 37 | 35 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect | |
| 38 | 36 | ) | |
| 39 | 37 | ||
| 40 | - | replace github.com/diamondburned/gotk4-webkitgtk/pkg => github.com/bnema/gotk4-webkitgtk/pkg v0.1.1 | |
| 38 | + | replace ( | |
| 39 | + | github.com/diamondburned/gotk4 => github.com/bnema/gotk4 v0.3.4-0.20251017144723-f765b03fd751 | |
| 40 | + | github.com/diamondburned/gotk4-webkitgtk => github.com/bnema/gotk4-webkitgtk v0.1.2-0.20251017152300-b3012dbe285f | |
| 41 | + | github.com/diamondburned/gotk4-webkitgtk/pkg => github.com/bnema/gotk4-webkitgtk/pkg v0.1.2-0.20251017152300-b3012dbe285f | |
| 42 | + | github.com/diamondburned/gotk4/pkg => github.com/bnema/gotk4/pkg v0.3.4-0.20251017144723-f765b03fd751 | |
| 43 | + | ) | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | module github.com/bnema/dumber | 1 | module github.com/bnema/dumber | ||
| 2 | 2 | ||||
| 3 | - | go 1.25.1 | 3 | + | go 1.25.3 |
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/diamondburned/gotk4-webkitgtk/pkg v0.0.0-20240108031600-dee1973cf440 | 6 | github.com/diamondburned/gotk4-webkitgtk/pkg v0.0.0-20240108031600-dee1973cf440 | ||
| @@ -8,9 +8,9 @@ require ( | |||||
| 8 | github.com/fsnotify/fsnotify v1.9.0 | 8 | github.com/fsnotify/fsnotify v1.9.0 | ||
| 9 | github.com/golang/mock v1.6.0 | 9 | github.com/golang/mock v1.6.0 | ||
| 10 | github.com/ncruces/go-sqlite3 v0.29.0 | 10 | github.com/ncruces/go-sqlite3 v0.29.0 | ||
| 11 | - | github.com/sergeymakinen/go-ico v1.0.0-beta.0 | |||
| 12 | github.com/spf13/cobra v1.10.1 | 11 | github.com/spf13/cobra v1.10.1 | ||
| 13 | github.com/spf13/viper v1.21.0 | 12 | github.com/spf13/viper v1.21.0 | ||
| 13 | + | golang.org/x/sys v0.36.0 | |||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | require ( | 16 | require ( | ||
| @@ -22,7 +22,6 @@ require ( | |||||
| 22 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | 22 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | ||
| 23 | github.com/rogpeppe/go-internal v1.12.0 // indirect | 23 | github.com/rogpeppe/go-internal v1.12.0 // indirect | ||
| 24 | github.com/sagikazarmark/locafero v0.11.0 // indirect | 24 | github.com/sagikazarmark/locafero v0.11.0 // indirect | ||
| 25 | - | github.com/sergeymakinen/go-bmp v1.0.0 // indirect | |||
| 26 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | 25 | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | ||
| 27 | github.com/spf13/afero v1.15.0 // indirect | 26 | github.com/spf13/afero v1.15.0 // indirect | ||
| 28 | github.com/spf13/cast v1.10.0 // indirect | 27 | github.com/spf13/cast v1.10.0 // indirect | ||
| @@ -32,9 +31,13 @@ require ( | |||||
| 32 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 31 | go.yaml.in/yaml/v3 v3.0.4 // indirect | ||
| 33 | go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect | 32 | go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect | ||
| 34 | golang.org/x/sync v0.17.0 // indirect | 33 | golang.org/x/sync v0.17.0 // indirect | ||
| 35 | - | golang.org/x/sys v0.36.0 // indirect | |||
| 36 | golang.org/x/text v0.29.0 // indirect | 34 | golang.org/x/text v0.29.0 // indirect | ||
| 37 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect | 35 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect | ||
| 38 | ) | 36 | ) | ||
| 39 | 37 | ||||
| 40 | - | replace github.com/diamondburned/gotk4-webkitgtk/pkg => github.com/bnema/gotk4-webkitgtk/pkg v0.1.1 | 38 | + | replace ( |
| 39 | + | github.com/diamondburned/gotk4 => github.com/bnema/gotk4 v0.3.4-0.20251017144723-f765b03fd751 | |||
| 40 | + | github.com/diamondburned/gotk4-webkitgtk => github.com/bnema/gotk4-webkitgtk v0.1.2-0.20251017152300-b3012dbe285f | |||
| 41 | + | github.com/diamondburned/gotk4-webkitgtk/pkg => github.com/bnema/gotk4-webkitgtk/pkg v0.1.2-0.20251017152300-b3012dbe285f | |||
| 42 | + | github.com/diamondburned/gotk4/pkg => github.com/bnema/gotk4/pkg v0.3.4-0.20251017144723-f765b03fd751 | |||
| 43 | + | ) | |||
M go.sum +4 -8 generated file: collapsed
M gui/src/components/omnibox/OmniboxSuggestions.svelte +6 -1
| @@ -190,17 +190,22 @@ | |||
| 190 | 190 | .suggestion-favicon { | |
| 191 | 191 | width: 20px; | |
| 192 | 192 | height: 20px; | |
| 193 | - | border: 1px solid var(--dynamic-border); | |
| 193 | + | border: none; | |
| 194 | + | border-radius: 3px; | |
| 194 | 195 | display: flex; | |
| 195 | 196 | align-items: center; | |
| 196 | 197 | justify-content: center; | |
| 197 | 198 | background: var(--dynamic-bg); | |
| 199 | + | flex-shrink: 0; | |
| 200 | + | overflow: hidden; | |
| 198 | 201 | } | |
| 199 | 202 | ||
| 200 | 203 | .suggestion-favicon-img { | |
| 201 | 204 | width: 16px; | |
| 202 | 205 | height: 16px; | |
| 203 | 206 | object-fit: contain; | |
| 207 | + | image-rendering: -webkit-optimize-contrast; | |
| 208 | + | -ms-interpolation-mode: nearest-neighbor; | |
| 204 | 209 | } | |
| 205 | 210 | ||
| 206 | 211 | .suggestion-text { | |
| @@ -190,17 +190,22 @@ | |||||
| 190 | .suggestion-favicon { | 190 | .suggestion-favicon { | ||
| 191 | width: 20px; | 191 | width: 20px; | ||
| 192 | height: 20px; | 192 | height: 20px; | ||
| 193 | - | border: 1px solid var(--dynamic-border); | 193 | + | border: none; |
| 194 | + | border-radius: 3px; | |||
| 194 | display: flex; | 195 | display: flex; | ||
| 195 | align-items: center; | 196 | align-items: center; | ||
| 196 | justify-content: center; | 197 | justify-content: center; | ||
| 197 | background: var(--dynamic-bg); | 198 | background: var(--dynamic-bg); | ||
| 199 | + | flex-shrink: 0; | |||
| 200 | + | overflow: hidden; | |||
| 198 | } | 201 | } | ||
| 199 | 202 | ||||
| 200 | .suggestion-favicon-img { | 203 | .suggestion-favicon-img { | ||
| 201 | width: 16px; | 204 | width: 16px; | ||
| 202 | height: 16px; | 205 | height: 16px; | ||
| 203 | object-fit: contain; | 206 | object-fit: contain; | ||
| 207 | + | image-rendering: -webkit-optimize-contrast; | |||
| 208 | + | -ms-interpolation-mode: nearest-neighbor; | |||
| 204 | } | 209 | } | ||
| 205 | 210 | ||||
| 206 | .suggestion-text { | 211 | .suggestion-text { | ||
M internal/app/browser/browser.go +1 -0
| @@ -36,6 +36,7 @@ type BrowserApp struct { | |||
| 36 | 36 | // Services | |
| 37 | 37 | parserService *services.ParserService | |
| 38 | 38 | browserService *services.BrowserService | |
| 39 | + | faviconService *services.FaviconService | |
| 39 | 40 | ||
| 40 | 41 | // WebView and controllers | |
| 41 | 42 | webView *webkit.WebView | |
| @@ -36,6 +36,7 @@ type BrowserApp struct { | |||||
| 36 | // Services | 36 | // Services | ||
| 37 | parserService *services.ParserService | 37 | parserService *services.ParserService | ||
| 38 | browserService *services.BrowserService | 38 | browserService *services.BrowserService | ||
| 39 | + | faviconService *services.FaviconService | |||
| 39 | 40 | ||||
| 40 | // WebView and controllers | 41 | // WebView and controllers | ||
| 41 | webView *webkit.WebView | 42 | webView *webkit.WebView | ||
M internal/app/browser/stacked_panes.go +33 -70
| @@ -1,15 +1,14 @@ | |||
| 1 | 1 | package browser | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "errors" | |
| 6 | 5 | "fmt" | |
| 7 | 6 | "log" | |
| 8 | 7 | "sync/atomic" | |
| 9 | 8 | "time" | |
| 10 | 9 | ||
| 11 | - | "github.com/bnema/dumber/internal/cache" | |
| 12 | 10 | "github.com/bnema/dumber/pkg/webkit" | |
| 11 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |
| 13 | 12 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 14 | 13 | ) | |
| 15 | 14 | ||
| @@ -664,75 +663,39 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str | |||
| 664 | 663 | titleBox.SetHExpand(true) | |
| 665 | 664 | titleBox.SetVExpand(false) | |
| 666 | 665 | ||
| 667 | - | // Add favicon placeholder for debugging (always visible) | |
| 668 | - | var faviconImg gtk.Widgetter | |
| 669 | - | var debugReason string | |
| 670 | - | ||
| 671 | - | if pageURL == "" { | |
| 672 | - | debugReason = "empty pageURL" | |
| 673 | - | } else if spm.wm == nil { | |
| 674 | - | debugReason = "nil workspace manager" | |
| 675 | - | } else if spm.wm.app == nil { | |
| 676 | - | debugReason = "nil app" | |
| 677 | - | } else if spm.wm.app.queries == nil { | |
| 678 | - | debugReason = "nil queries" | |
| 679 | - | } else { | |
| 680 | - | // Query database for favicon URL | |
| 681 | - | ctx := context.Background() | |
| 682 | - | entry, err := spm.wm.app.queries.GetHistoryEntry(ctx, pageURL) | |
| 683 | - | if err != nil { | |
| 684 | - | debugReason = fmt.Sprintf("DB query failed: %v", err) | |
| 685 | - | log.Printf("[favicon] Failed to get history entry for %s: %v", pageURL, err) | |
| 686 | - | } else if !entry.FaviconUrl.Valid || entry.FaviconUrl.String == "" { | |
| 687 | - | debugReason = "no favicon URL in DB" | |
| 688 | - | log.Printf("[favicon] No favicon URL in DB for %s", pageURL) | |
| 689 | - | } else { | |
| 690 | - | faviconURL := entry.FaviconUrl.String | |
| 691 | - | log.Printf("[favicon] Found favicon URL in DB for %s: %s", pageURL, faviconURL) | |
| 666 | + | placeholderLabel := gtk.NewLabel(faviconPlaceholder) | |
| 667 | + | if placeholderLabel != nil { | |
| 668 | + | placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder") | |
| 669 | + | titleBox.Append(placeholderLabel) | |
| 670 | + | webkit.WidgetSetVisible(placeholderLabel, true) | |
| 671 | + | } | |
| 692 | 672 | ||
| 693 | - | // Use favicon cache with favicon URL (same as dmenu) | |
| 694 | - | faviconCache, err := cache.NewFaviconCache() | |
| 673 | + | if pageURL != "" && spm.wm != nil && spm.wm.app != nil && spm.wm.app.faviconService != nil { | |
| 674 | + | spm.wm.app.faviconService.GetFaviconTexture(pageURL, func(texture *gdk.Texture, err error) { | |
| 695 | 675 | if err != nil { | |
| 696 | - | debugReason = fmt.Sprintf("cache init failed: %v", err) | |
| 697 | - | log.Printf("[favicon] Failed to create cache: %v", err) | |
| 698 | - | } else { | |
| 699 | - | faviconPath := faviconCache.GetCachedPath(faviconURL) | |
| 700 | - | if faviconPath != "" { | |
| 701 | - | log.Printf("[favicon] Loading cached favicon from: %s", faviconPath) | |
| 702 | - | faviconImg = gtk.NewImageFromFile(faviconPath) | |
| 703 | - | if faviconImg != nil { | |
| 704 | - | if img, ok := faviconImg.(*gtk.Image); ok { | |
| 705 | - | img.SetPixelSize(16) | |
| 706 | - | } | |
| 707 | - | webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon") | |
| 708 | - | log.Printf("[favicon] Successfully created favicon image widget") | |
| 709 | - | } else { | |
| 710 | - | debugReason = "image widget creation failed" | |
| 711 | - | log.Printf("[favicon] Failed to create image widget from %s", faviconPath) | |
| 712 | - | } | |
| 713 | - | } else { | |
| 714 | - | debugReason = "not cached yet" | |
| 715 | - | log.Printf("[favicon] Favicon not cached yet, starting download: %s", faviconURL) | |
| 716 | - | // Start async download for next time (same as dmenu) | |
| 717 | - | faviconCache.CacheAsync(faviconURL) | |
| 718 | - | } | |
| 676 | + | log.Printf("[favicon] Failed to load favicon for title bar: %s - %v", pageURL, err) | |
| 677 | + | return | |
| 719 | 678 | } | |
| 720 | - | } | |
| 721 | - | } | |
| 722 | 679 | ||
| 723 | - | // Add favicon or placeholder | |
| 724 | - | if faviconImg != nil { | |
| 725 | - | titleBox.Append(faviconImg) | |
| 726 | - | webkit.WidgetShow(faviconImg) | |
| 727 | - | } else { | |
| 728 | - | // DEBUG: Add visible placeholder label to show code is running | |
| 729 | - | placeholderLabel := gtk.NewLabel(faviconPlaceholder) | |
| 730 | - | if placeholderLabel != nil { | |
| 731 | - | placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder") | |
| 732 | - | titleBox.Append(placeholderLabel) | |
| 733 | - | placeholderLabel.Show() | |
| 734 | - | log.Printf("[favicon] Using placeholder for %s (reason: %s)", pageURL, debugReason) | |
| 735 | - | } | |
| 680 | + | if texture != nil && titleBox != nil { | |
| 681 | + | log.Printf("[favicon] Favicon ready to display in title bar for: %s", pageURL) | |
| 682 | + | ||
| 683 | + | // Create favicon image | |
| 684 | + | faviconImg := gtk.NewImageFromPaintable(texture) | |
| 685 | + | faviconImg.SetPixelSize(16) | |
| 686 | + | webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon") | |
| 687 | + | ||
| 688 | + | // IDEMPOTENT: Remove placeholder only if it still has titleBox as parent | |
| 689 | + | // This handles the case where multiple async callbacks fire for the same title bar | |
| 690 | + | if placeholderLabel != nil && webkit.WidgetGetParent(placeholderLabel) != nil { | |
| 691 | + | titleBox.Remove(placeholderLabel) | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | // Prepend favicon image (multiple calls are safe - GTK handles it) | |
| 695 | + | titleBox.Prepend(faviconImg) | |
| 696 | + | webkit.WidgetSetVisible(faviconImg, true) | |
| 697 | + | } | |
| 698 | + | }) | |
| 736 | 699 | } | |
| 737 | 700 | ||
| 738 | 701 | // Create title label with the specified title | |
| @@ -742,11 +705,11 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str | |||
| 742 | 705 | } | |
| 743 | 706 | ||
| 744 | 707 | titleLabel.AddCSSClass("stacked-pane-title-text") | |
| 745 | - | titleLabel.SetEllipsize(2) // pango.EllipsizeEnd = 2 | |
| 708 | + | titleLabel.SetEllipsize(2) | |
| 746 | 709 | ||
| 747 | 710 | titleBox.Append(titleLabel) | |
| 748 | - | titleBox.Show() | |
| 749 | - | titleLabel.Show() | |
| 711 | + | webkit.WidgetSetVisible(titleBox, true) | |
| 712 | + | webkit.WidgetSetVisible(titleLabel, true) | |
| 750 | 713 | ||
| 751 | 714 | return titleBox | |
| 752 | 715 | } | |
| @@ -1,15 +1,14 @@ | |||||
| 1 | package browser | 1 | package browser | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "context" | |||
| 5 | "errors" | 4 | "errors" | ||
| 6 | "fmt" | 5 | "fmt" | ||
| 7 | "log" | 6 | "log" | ||
| 8 | "sync/atomic" | 7 | "sync/atomic" | ||
| 9 | "time" | 8 | "time" | ||
| 10 | 9 | ||||
| 11 | - | "github.com/bnema/dumber/internal/cache" | |||
| 12 | "github.com/bnema/dumber/pkg/webkit" | 10 | "github.com/bnema/dumber/pkg/webkit" | ||
| 11 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |||
| 13 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | 12 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | ||
| 14 | ) | 13 | ) | ||
| 15 | 14 | ||||
| @@ -664,75 +663,39 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str | |||||
| 664 | titleBox.SetHExpand(true) | 663 | titleBox.SetHExpand(true) | ||
| 665 | titleBox.SetVExpand(false) | 664 | titleBox.SetVExpand(false) | ||
| 666 | 665 | ||||
| 667 | - | // Add favicon placeholder for debugging (always visible) | 666 | + | placeholderLabel := gtk.NewLabel(faviconPlaceholder) |
| 668 | - | var faviconImg gtk.Widgetter | 667 | + | if placeholderLabel != nil { |
| 669 | - | var debugReason string | 668 | + | placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder") |
| 670 | - | 669 | + | titleBox.Append(placeholderLabel) | |
| 671 | - | if pageURL == "" { | 670 | + | webkit.WidgetSetVisible(placeholderLabel, true) |
| 672 | - | debugReason = "empty pageURL" | 671 | + | } |
| 673 | - | } else if spm.wm == nil { | |||
| 674 | - | debugReason = "nil workspace manager" | |||
| 675 | - | } else if spm.wm.app == nil { | |||
| 676 | - | debugReason = "nil app" | |||
| 677 | - | } else if spm.wm.app.queries == nil { | |||
| 678 | - | debugReason = "nil queries" | |||
| 679 | - | } else { | |||
| 680 | - | // Query database for favicon URL | |||
| 681 | - | ctx := context.Background() | |||
| 682 | - | entry, err := spm.wm.app.queries.GetHistoryEntry(ctx, pageURL) | |||
| 683 | - | if err != nil { | |||
| 684 | - | debugReason = fmt.Sprintf("DB query failed: %v", err) | |||
| 685 | - | log.Printf("[favicon] Failed to get history entry for %s: %v", pageURL, err) | |||
| 686 | - | } else if !entry.FaviconUrl.Valid || entry.FaviconUrl.String == "" { | |||
| 687 | - | debugReason = "no favicon URL in DB" | |||
| 688 | - | log.Printf("[favicon] No favicon URL in DB for %s", pageURL) | |||
| 689 | - | } else { | |||
| 690 | - | faviconURL := entry.FaviconUrl.String | |||
| 691 | - | log.Printf("[favicon] Found favicon URL in DB for %s: %s", pageURL, faviconURL) | |||
| 692 | 672 | ||||
| 693 | - | // Use favicon cache with favicon URL (same as dmenu) | 673 | + | if pageURL != "" && spm.wm != nil && spm.wm.app != nil && spm.wm.app.faviconService != nil { |
| 694 | - | faviconCache, err := cache.NewFaviconCache() | 674 | + | spm.wm.app.faviconService.GetFaviconTexture(pageURL, func(texture *gdk.Texture, err error) { |
| 695 | if err != nil { | 675 | if err != nil { | ||
| 696 | - | debugReason = fmt.Sprintf("cache init failed: %v", err) | 676 | + | log.Printf("[favicon] Failed to load favicon for title bar: %s - %v", pageURL, err) |
| 697 | - | log.Printf("[favicon] Failed to create cache: %v", err) | 677 | + | return |
| 698 | - | } else { | |||
| 699 | - | faviconPath := faviconCache.GetCachedPath(faviconURL) | |||
| 700 | - | if faviconPath != "" { | |||
| 701 | - | log.Printf("[favicon] Loading cached favicon from: %s", faviconPath) | |||
| 702 | - | faviconImg = gtk.NewImageFromFile(faviconPath) | |||
| 703 | - | if faviconImg != nil { | |||
| 704 | - | if img, ok := faviconImg.(*gtk.Image); ok { | |||
| 705 | - | img.SetPixelSize(16) | |||
| 706 | - | } | |||
| 707 | - | webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon") | |||
| 708 | - | log.Printf("[favicon] Successfully created favicon image widget") | |||
| 709 | - | } else { | |||
| 710 | - | debugReason = "image widget creation failed" | |||
| 711 | - | log.Printf("[favicon] Failed to create image widget from %s", faviconPath) | |||
| 712 | - | } | |||
| 713 | - | } else { | |||
| 714 | - | debugReason = "not cached yet" | |||
| 715 | - | log.Printf("[favicon] Favicon not cached yet, starting download: %s", faviconURL) | |||
| 716 | - | // Start async download for next time (same as dmenu) | |||
| 717 | - | faviconCache.CacheAsync(faviconURL) | |||
| 718 | - | } | |||
| 719 | } | 678 | } | ||
| 720 | - | } | |||
| 721 | - | } | |||
| 722 | 679 | ||||
| 723 | - | // Add favicon or placeholder | 680 | + | if texture != nil && titleBox != nil { |
| 724 | - | if faviconImg != nil { | 681 | + | log.Printf("[favicon] Favicon ready to display in title bar for: %s", pageURL) |
| 725 | - | titleBox.Append(faviconImg) | 682 | + | |
| 726 | - | webkit.WidgetShow(faviconImg) | 683 | + | // Create favicon image |
| 727 | - | } else { | 684 | + | faviconImg := gtk.NewImageFromPaintable(texture) |
| 728 | - | // DEBUG: Add visible placeholder label to show code is running | 685 | + | faviconImg.SetPixelSize(16) |
| 729 | - | placeholderLabel := gtk.NewLabel(faviconPlaceholder) | 686 | + | webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon") |
| 730 | - | if placeholderLabel != nil { | 687 | + | |
| 731 | - | placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder") | 688 | + | // IDEMPOTENT: Remove placeholder only if it still has titleBox as parent |
| 732 | - | titleBox.Append(placeholderLabel) | 689 | + | // This handles the case where multiple async callbacks fire for the same title bar |
| 733 | - | placeholderLabel.Show() | 690 | + | if placeholderLabel != nil && webkit.WidgetGetParent(placeholderLabel) != nil { |
| 734 | - | log.Printf("[favicon] Using placeholder for %s (reason: %s)", pageURL, debugReason) | 691 | + | titleBox.Remove(placeholderLabel) |
| 735 | - | } | 692 | + | } |
| 693 | + | ||||
| 694 | + | // Prepend favicon image (multiple calls are safe - GTK handles it) | |||
| 695 | + | titleBox.Prepend(faviconImg) | |||
| 696 | + | webkit.WidgetSetVisible(faviconImg, true) | |||
| 697 | + | } | |||
| 698 | + | }) | |||
| 736 | } | 699 | } | ||
| 737 | 700 | ||||
| 738 | // Create title label with the specified title | 701 | // Create title label with the specified title | ||
| @@ -742,11 +705,11 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str | |||||
| 742 | } | 705 | } | ||
| 743 | 706 | ||||
| 744 | titleLabel.AddCSSClass("stacked-pane-title-text") | 707 | titleLabel.AddCSSClass("stacked-pane-title-text") | ||
| 745 | - | titleLabel.SetEllipsize(2) // pango.EllipsizeEnd = 2 | 708 | + | titleLabel.SetEllipsize(2) |
| 746 | 709 | ||||
| 747 | titleBox.Append(titleLabel) | 710 | titleBox.Append(titleLabel) | ||
| 748 | - | titleBox.Show() | 711 | + | webkit.WidgetSetVisible(titleBox, true) |
| 749 | - | titleLabel.Show() | 712 | + | webkit.WidgetSetVisible(titleLabel, true) |
| 750 | 713 | ||||
| 751 | return titleBox | 714 | return titleBox | ||
| 752 | } | 715 | } | ||
M internal/app/browser/webview.go +33 -9
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "github.com/bnema/dumber/internal/app/messaging" | |
| 15 | 15 | "github.com/bnema/dumber/internal/config" | |
| 16 | 16 | "github.com/bnema/dumber/internal/filtering" | |
| 17 | + | "github.com/bnema/dumber/internal/services" | |
| 17 | 18 | "github.com/bnema/dumber/pkg/webkit" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| @@ -132,15 +133,6 @@ func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) { | |||
| 132 | 133 | app.workspace.RegisterNavigationHandler(view) | |
| 133 | 134 | } | |
| 134 | 135 | ||
| 135 | - | // Register favicon handlers for this webview (needed for each pane) | |
| 136 | - | // This ensures favicons are detected and cached for all panes, not just the main webview | |
| 137 | - | view.RegisterFaviconURIChangedHandler(func(pageURI, faviconURI string) { | |
| 138 | - | // Call the private handler through a public wrapper or directly via browserService | |
| 139 | - | // We can't call handleFaviconURIChanged directly as it's private, so we need to expose it | |
| 140 | - | // For now, duplicate the minimal logic here | |
| 141 | - | app.browserService.ProcessFaviconURI(pageURI, faviconURI) | |
| 142 | - | }) | |
| 143 | - | ||
| 144 | 136 | return pane, nil | |
| 145 | 137 | } | |
| 146 | 138 | ||
| @@ -287,6 +279,12 @@ func (app *BrowserApp) createWebView() error { | |||
| 287 | 279 | ||
| 288 | 280 | app.setupWebViewIntegration() | |
| 289 | 281 | ||
| 282 | + | // Initialize FaviconService after WebView is created | |
| 283 | + | if err := app.initializeFaviconService(); err != nil { | |
| 284 | + | log.Printf("Warning: failed to initialize favicon service: %v", err) | |
| 285 | + | // Continue without favicon service - not critical | |
| 286 | + | } | |
| 287 | + | ||
| 290 | 288 | // Apply initial zoom and show window | |
| 291 | 289 | app.zoomController.ApplyInitialZoom() | |
| 292 | 290 | ||
| @@ -313,6 +311,32 @@ func (app *BrowserApp) setupWebViewIntegration() { | |||
| 313 | 311 | } | |
| 314 | 312 | } | |
| 315 | 313 | ||
| 314 | + | // initializeFaviconService creates and initializes the FaviconService | |
| 315 | + | func (app *BrowserApp) initializeFaviconService() error { | |
| 316 | + | // Get the FaviconDatabase from the WebView | |
| 317 | + | faviconDB := app.webView.GetFaviconDatabase() | |
| 318 | + | if faviconDB == nil { | |
| 319 | + | return fmt.Errorf("favicon database not available") | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // Get the WebKit data directory | |
| 323 | + | dataDir, err := config.GetDataDir() | |
| 324 | + | if err != nil { | |
| 325 | + | return fmt.Errorf("failed to get data directory: %w", err) | |
| 326 | + | } | |
| 327 | + | webkitData := filepath.Join(dataDir, "webkit") | |
| 328 | + | ||
| 329 | + | // Create FaviconService | |
| 330 | + | faviconService, err := services.NewFaviconService(faviconDB, app.queries, webkitData) | |
| 331 | + | if err != nil { | |
| 332 | + | return fmt.Errorf("failed to create favicon service: %w", err) | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | app.faviconService = faviconService | |
| 336 | + | log.Printf("[favicon] FaviconService initialized successfully") | |
| 337 | + | return nil | |
| 338 | + | } | |
| 339 | + | ||
| 316 | 340 | // setupContentBlocking initializes the content blocking system with proper timing | |
| 317 | 341 | func (app *BrowserApp) setupContentBlocking() error { | |
| 318 | 342 | log.Printf("Initializing content blocking system...") | |
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | "github.com/bnema/dumber/internal/app/messaging" | 14 | "github.com/bnema/dumber/internal/app/messaging" | ||
| 15 | "github.com/bnema/dumber/internal/config" | 15 | "github.com/bnema/dumber/internal/config" | ||
| 16 | "github.com/bnema/dumber/internal/filtering" | 16 | "github.com/bnema/dumber/internal/filtering" | ||
| 17 | + | "github.com/bnema/dumber/internal/services" | |||
| 17 | "github.com/bnema/dumber/pkg/webkit" | 18 | "github.com/bnema/dumber/pkg/webkit" | ||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| @@ -132,15 +133,6 @@ func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) { | |||||
| 132 | app.workspace.RegisterNavigationHandler(view) | 133 | app.workspace.RegisterNavigationHandler(view) | ||
| 133 | } | 134 | } | ||
| 134 | 135 | ||||
| 135 | - | // Register favicon handlers for this webview (needed for each pane) | |||
| 136 | - | // This ensures favicons are detected and cached for all panes, not just the main webview | |||
| 137 | - | view.RegisterFaviconURIChangedHandler(func(pageURI, faviconURI string) { | |||
| 138 | - | // Call the private handler through a public wrapper or directly via browserService | |||
| 139 | - | // We can't call handleFaviconURIChanged directly as it's private, so we need to expose it | |||
| 140 | - | // For now, duplicate the minimal logic here | |||
| 141 | - | app.browserService.ProcessFaviconURI(pageURI, faviconURI) | |||
| 142 | - | }) | |||
| 143 | - | ||||
| 144 | return pane, nil | 136 | return pane, nil | ||
| 145 | } | 137 | } | ||
| 146 | 138 | ||||
| @@ -287,6 +279,12 @@ func (app *BrowserApp) createWebView() error { | |||||
| 287 | 279 | ||||
| 288 | app.setupWebViewIntegration() | 280 | app.setupWebViewIntegration() | ||
| 289 | 281 | ||||
| 282 | + | // Initialize FaviconService after WebView is created | |||
| 283 | + | if err := app.initializeFaviconService(); err != nil { | |||
| 284 | + | log.Printf("Warning: failed to initialize favicon service: %v", err) | |||
| 285 | + | // Continue without favicon service - not critical | |||
| 286 | + | } | |||
| 287 | + | ||||
| 290 | // Apply initial zoom and show window | 288 | // Apply initial zoom and show window | ||
| 291 | app.zoomController.ApplyInitialZoom() | 289 | app.zoomController.ApplyInitialZoom() | ||
| 292 | 290 | ||||
| @@ -313,6 +311,32 @@ func (app *BrowserApp) setupWebViewIntegration() { | |||||
| 313 | } | 311 | } | ||
| 314 | } | 312 | } | ||
| 315 | 313 | ||||
| 314 | + | // initializeFaviconService creates and initializes the FaviconService | |||
| 315 | + | func (app *BrowserApp) initializeFaviconService() error { | |||
| 316 | + | // Get the FaviconDatabase from the WebView | |||
| 317 | + | faviconDB := app.webView.GetFaviconDatabase() | |||
| 318 | + | if faviconDB == nil { | |||
| 319 | + | return fmt.Errorf("favicon database not available") | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | // Get the WebKit data directory | |||
| 323 | + | dataDir, err := config.GetDataDir() | |||
| 324 | + | if err != nil { | |||
| 325 | + | return fmt.Errorf("failed to get data directory: %w", err) | |||
| 326 | + | } | |||
| 327 | + | webkitData := filepath.Join(dataDir, "webkit") | |||
| 328 | + | ||||
| 329 | + | // Create FaviconService | |||
| 330 | + | faviconService, err := services.NewFaviconService(faviconDB, app.queries, webkitData) | |||
| 331 | + | if err != nil { | |||
| 332 | + | return fmt.Errorf("failed to create favicon service: %w", err) | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | app.faviconService = faviconService | |||
| 336 | + | log.Printf("[favicon] FaviconService initialized successfully") | |||
| 337 | + | return nil | |||
| 338 | + | } | |||
| 339 | + | ||||
| 316 | // setupContentBlocking initializes the content blocking system with proper timing | 340 | // setupContentBlocking initializes the content blocking system with proper timing | ||
| 317 | func (app *BrowserApp) setupContentBlocking() error { | 341 | func (app *BrowserApp) setupContentBlocking() error { | ||
| 318 | log.Printf("Initializing content blocking system...") | 342 | log.Printf("Initializing content blocking system...") | ||
M internal/app/browser/workspace_pane_ops.go +1 -1
| @@ -908,7 +908,7 @@ func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) { | |||
| 908 | 908 | log.Printf("[workspace] closing final pane; exiting browser") | |
| 909 | 909 | wm.cleanupPane(node, wm.nextCleanupGeneration()) | |
| 910 | 910 | wm.detachHover(node) | |
| 911 | - | ||
| 911 | + | ||
| 912 | 912 | // Gracefully quit the GTK main loop | |
| 913 | 913 | webkit.QuitMainLoop() | |
| 914 | 914 | return nil, nil | |
| @@ -908,7 +908,7 @@ func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) { | |||||
| 908 | log.Printf("[workspace] closing final pane; exiting browser") | 908 | log.Printf("[workspace] closing final pane; exiting browser") | ||
| 909 | wm.cleanupPane(node, wm.nextCleanupGeneration()) | 909 | wm.cleanupPane(node, wm.nextCleanupGeneration()) | ||
| 910 | wm.detachHover(node) | 910 | wm.detachHover(node) | ||
| 911 | - | 911 | + | ||
| 912 | // Gracefully quit the GTK main loop | 912 | // Gracefully quit the GTK main loop | ||
| 913 | webkit.QuitMainLoop() | 913 | webkit.QuitMainLoop() | ||
| 914 | return nil, nil | 914 | return nil, nil | ||
M internal/app/messaging/handler.go +1 -31
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "log" | |
| 7 | 7 | "strconv" | |
| 8 | - | "strings" | |
| 9 | 8 | "time" | |
| 10 | 9 | ||
| 11 | 10 | "github.com/bnema/dumber/internal/app/constants" | |
| @@ -226,33 +225,6 @@ func (h *Handler) handleQuery(msg Message) { | |||
| 226 | 225 | Favicon string `json:"favicon,omitempty"` | |
| 227 | 226 | } | |
| 228 | 227 | ||
| 229 | - | buildFavicon := func(raw string) string { | |
| 230 | - | if raw == "" { | |
| 231 | - | return "" | |
| 232 | - | } | |
| 233 | - | scheme := "https" | |
| 234 | - | host := raw | |
| 235 | - | if i := strings.Index(raw, "://"); i >= 0 { | |
| 236 | - | if i > 0 { | |
| 237 | - | scheme = raw[:i] | |
| 238 | - | } | |
| 239 | - | rest := raw[i+3:] | |
| 240 | - | if j := strings.IndexByte(rest, '/'); j >= 0 { | |
| 241 | - | host = rest[:j] | |
| 242 | - | } else { | |
| 243 | - | host = rest | |
| 244 | - | } | |
| 245 | - | } else { | |
| 246 | - | if j := strings.IndexByte(raw, '/'); j >= 0 { | |
| 247 | - | host = raw[:j] | |
| 248 | - | } | |
| 249 | - | } | |
| 250 | - | if host == "" { | |
| 251 | - | return "" | |
| 252 | - | } | |
| 253 | - | return scheme + "://" + host + "/favicon.ico" | |
| 254 | - | } | |
| 255 | - | ||
| 256 | 228 | results := make([]suggestion, 0, limit) | |
| 257 | 229 | seen := make(map[string]struct{}, limit*2) | |
| 258 | 230 | ||
| @@ -284,12 +256,10 @@ func (h *Handler) handleQuery(msg Message) { | |||
| 284 | 256 | if _, ok := seen[url]; ok { | |
| 285 | 257 | continue | |
| 286 | 258 | } | |
| 287 | - | // Use favicon_url from database if available, otherwise build it | |
| 259 | + | // Use favicon_url from database (populated by FaviconService) | |
| 288 | 260 | favicon := "" | |
| 289 | 261 | if s, ok := m["favicon_url"].(string); ok && s != "" { | |
| 290 | 262 | favicon = s | |
| 291 | - | } else { | |
| 292 | - | favicon = buildFavicon(url) | |
| 293 | 263 | } | |
| 294 | 264 | results = append(results, suggestion{URL: url, Favicon: favicon}) | |
| 295 | 265 | seen[url] = struct{}{} | |
| @@ -5,7 +5,6 @@ import ( | |||||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "log" | 6 | "log" | ||
| 7 | "strconv" | 7 | "strconv" | ||
| 8 | - | "strings" | |||
| 9 | "time" | 8 | "time" | ||
| 10 | 9 | ||||
| 11 | "github.com/bnema/dumber/internal/app/constants" | 10 | "github.com/bnema/dumber/internal/app/constants" | ||
| @@ -226,33 +225,6 @@ func (h *Handler) handleQuery(msg Message) { | |||||
| 226 | Favicon string `json:"favicon,omitempty"` | 225 | Favicon string `json:"favicon,omitempty"` | ||
| 227 | } | 226 | } | ||
| 228 | 227 | ||||
| 229 | - | buildFavicon := func(raw string) string { | |||
| 230 | - | if raw == "" { | |||
| 231 | - | return "" | |||
| 232 | - | } | |||
| 233 | - | scheme := "https" | |||
| 234 | - | host := raw | |||
| 235 | - | if i := strings.Index(raw, "://"); i >= 0 { | |||
| 236 | - | if i > 0 { | |||
| 237 | - | scheme = raw[:i] | |||
| 238 | - | } | |||
| 239 | - | rest := raw[i+3:] | |||
| 240 | - | if j := strings.IndexByte(rest, '/'); j >= 0 { | |||
| 241 | - | host = rest[:j] | |||
| 242 | - | } else { | |||
| 243 | - | host = rest | |||
| 244 | - | } | |||
| 245 | - | } else { | |||
| 246 | - | if j := strings.IndexByte(raw, '/'); j >= 0 { | |||
| 247 | - | host = raw[:j] | |||
| 248 | - | } | |||
| 249 | - | } | |||
| 250 | - | if host == "" { | |||
| 251 | - | return "" | |||
| 252 | - | } | |||
| 253 | - | return scheme + "://" + host + "/favicon.ico" | |||
| 254 | - | } | |||
| 255 | - | ||||
| 256 | results := make([]suggestion, 0, limit) | 228 | results := make([]suggestion, 0, limit) | ||
| 257 | seen := make(map[string]struct{}, limit*2) | 229 | seen := make(map[string]struct{}, limit*2) | ||
| 258 | 230 | ||||
| @@ -284,12 +256,10 @@ func (h *Handler) handleQuery(msg Message) { | |||||
| 284 | if _, ok := seen[url]; ok { | 256 | if _, ok := seen[url]; ok { | ||
| 285 | continue | 257 | continue | ||
| 286 | } | 258 | } | ||
| 287 | - | // Use favicon_url from database if available, otherwise build it | 259 | + | // Use favicon_url from database (populated by FaviconService) |
| 288 | favicon := "" | 260 | favicon := "" | ||
| 289 | if s, ok := m["favicon_url"].(string); ok && s != "" { | 261 | if s, ok := m["favicon_url"].(string); ok && s != "" { | ||
| 290 | favicon = s | 262 | favicon = s | ||
| 291 | - | } else { | |||
| 292 | - | favicon = buildFavicon(url) | |||
| 293 | } | 263 | } | ||
| 294 | results = append(results, suggestion{URL: url, Favicon: favicon}) | 264 | results = append(results, suggestion{URL: url, Favicon: favicon}) | ||
| 295 | seen[url] = struct{}{} | 265 | seen[url] = struct{}{} | ||
D internal/cache/favicon.go +0 -472
| @@ -1,472 +0,0 @@ | |||
| 1 | - | package cache | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "crypto/md5" | |
| 5 | - | "fmt" | |
| 6 | - | "image" | |
| 7 | - | "image/color" | |
| 8 | - | _ "image/gif" // Register GIF decoder | |
| 9 | - | _ "image/jpeg" // Register JPEG decoder | |
| 10 | - | "image/png" | |
| 11 | - | "io" | |
| 12 | - | "log" | |
| 13 | - | "net/http" | |
| 14 | - | "os" | |
| 15 | - | "path/filepath" | |
| 16 | - | "strings" | |
| 17 | - | "sync" | |
| 18 | - | "time" | |
| 19 | - | ||
| 20 | - | "github.com/bnema/dumber/internal/config" | |
| 21 | - | "github.com/sergeymakinen/go-ico" | |
| 22 | - | ) | |
| 23 | - | ||
| 24 | - | // FaviconCache manages favicon caching to local files | |
| 25 | - | type FaviconCache struct { | |
| 26 | - | cacheDir string | |
| 27 | - | downloadMutex sync.Mutex // Prevents concurrent downloads of the same favicon | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | // NewFaviconCache creates a new favicon cache | |
| 31 | - | func NewFaviconCache() (*FaviconCache, error) { | |
| 32 | - | dataDir, err := config.GetDataDir() | |
| 33 | - | if err != nil { | |
| 34 | - | return nil, fmt.Errorf("failed to get data directory: %w", err) | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | cacheDir := filepath.Join(dataDir, "favicons") | |
| 38 | - | if err := os.MkdirAll(cacheDir, 0755); err != nil { | |
| 39 | - | return nil, fmt.Errorf("failed to create favicon cache directory: %w", err) | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | return &FaviconCache{ | |
| 43 | - | cacheDir: cacheDir, | |
| 44 | - | }, nil | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | // GetCachedPath returns the local file path for a favicon URL if cached, empty string otherwise | |
| 48 | - | func (fc *FaviconCache) GetCachedPath(faviconURL string) string { | |
| 49 | - | if faviconURL == "" { | |
| 50 | - | return "" | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | filename := fc.getFilename(faviconURL) | |
| 54 | - | cachedPath := filepath.Join(fc.cacheDir, filename) | |
| 55 | - | ||
| 56 | - | // Check if file exists and is not too old (7 days) | |
| 57 | - | if info, err := os.Stat(cachedPath); err == nil { | |
| 58 | - | if time.Since(info.ModTime()) < 7*24*time.Hour { | |
| 59 | - | return cachedPath | |
| 60 | - | } | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | return "" | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // CacheAsync downloads and caches a favicon asynchronously | |
| 67 | - | func (fc *FaviconCache) CacheAsync(faviconURL string) { | |
| 68 | - | if faviconURL == "" { | |
| 69 | - | return | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | go func() { | |
| 73 | - | if err := fc.downloadFavicon(faviconURL); err != nil { | |
| 74 | - | // Silently ignore errors in background download | |
| 75 | - | } | |
| 76 | - | }() | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | // CachePNGData caches PNG favicon data directly for a URL | |
| 80 | - | func (fc *FaviconCache) CachePNGData(pageURL string, pngData []byte) error { | |
| 81 | - | if pageURL == "" || len(pngData) == 0 { | |
| 82 | - | return fmt.Errorf("invalid input: empty URL or data") | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | filename := fc.getFilename(pageURL) | |
| 86 | - | cachedPath := filepath.Join(fc.cacheDir, filename) | |
| 87 | - | ||
| 88 | - | // Write PNG data directly to cache | |
| 89 | - | return os.WriteFile(cachedPath, pngData, 0644) | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | // GetCachedPathForURL returns the cached path for a page URL (not favicon URL) | |
| 93 | - | func (fc *FaviconCache) GetCachedPathForURL(pageURL string) string { | |
| 94 | - | if pageURL == "" { | |
| 95 | - | return "" | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | filename := fc.getFilename(pageURL) | |
| 99 | - | cachedPath := filepath.Join(fc.cacheDir, filename) | |
| 100 | - | ||
| 101 | - | // Check if file exists and is not too old (7 days) | |
| 102 | - | if info, err := os.Stat(cachedPath); err == nil { | |
| 103 | - | if time.Since(info.ModTime()) < 7*24*time.Hour { | |
| 104 | - | return cachedPath | |
| 105 | - | } | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | return "" | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | // getFilename generates a safe filename for a favicon URL - always PNG for fuzzel compatibility | |
| 112 | - | func (fc *FaviconCache) getFilename(faviconURL string) string { | |
| 113 | - | hash := fmt.Sprintf("%x", md5.Sum([]byte(faviconURL))) | |
| 114 | - | return hash + ".png" | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | // downloadFavicon downloads a favicon and saves it to cache | |
| 118 | - | func (fc *FaviconCache) downloadFavicon(faviconURL string) error { | |
| 119 | - | // Skip SVG files - we can't convert them to PNG without additional dependencies | |
| 120 | - | if strings.HasSuffix(strings.ToLower(faviconURL), ".svg") { | |
| 121 | - | log.Printf("[favicon] Skipping SVG favicon: %s", faviconURL) | |
| 122 | - | return nil | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | filename := fc.getFilename(faviconURL) | |
| 126 | - | cachedPath := filepath.Join(fc.cacheDir, filename) | |
| 127 | - | ||
| 128 | - | // Lock to prevent concurrent downloads of the same favicon | |
| 129 | - | // This prevents race conditions on temp files and duplicate downloads | |
| 130 | - | fc.downloadMutex.Lock() | |
| 131 | - | defer fc.downloadMutex.Unlock() | |
| 132 | - | ||
| 133 | - | // Double-check if already exists after acquiring lock | |
| 134 | - | // Another goroutine might have downloaded it while we were waiting | |
| 135 | - | if info, err := os.Stat(cachedPath); err == nil { | |
| 136 | - | if time.Since(info.ModTime()) < 7*24*time.Hour { | |
| 137 | - | return nil | |
| 138 | - | } | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | // Create HTTP client with timeout and redirect handling | |
| 142 | - | client := &http.Client{ | |
| 143 | - | Timeout: 10 * time.Second, | |
| 144 | - | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |
| 145 | - | if len(via) >= 5 { | |
| 146 | - | return fmt.Errorf("too many redirects") | |
| 147 | - | } | |
| 148 | - | return nil | |
| 149 | - | }, | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | resp, err := client.Get(faviconURL) | |
| 153 | - | if err != nil { | |
| 154 | - | return fmt.Errorf("failed to download favicon: %w", err) | |
| 155 | - | } | |
| 156 | - | defer func() { | |
| 157 | - | if err := resp.Body.Close(); err != nil { | |
| 158 | - | log.Printf("[favicon] failed to close response body: %v", err) | |
| 159 | - | } | |
| 160 | - | }() | |
| 161 | - | ||
| 162 | - | // Log redirects for debugging | |
| 163 | - | if resp.Request.URL.String() != faviconURL { | |
| 164 | - | log.Printf("[favicon] Redirected from %s to %s", faviconURL, resp.Request.URL.String()) | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | if resp.StatusCode != http.StatusOK { | |
| 168 | - | return fmt.Errorf("failed to download favicon: status %d", resp.StatusCode) | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | // Limit response body size | |
| 172 | - | limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max | |
| 173 | - | ||
| 174 | - | // Download to temporary file first | |
| 175 | - | tempDownloadFile := cachedPath + ".download" | |
| 176 | - | downloadFile, err := os.Create(tempDownloadFile) | |
| 177 | - | if err != nil { | |
| 178 | - | return fmt.Errorf("failed to create download temp file: %w", err) | |
| 179 | - | } | |
| 180 | - | defer func() { | |
| 181 | - | if err := downloadFile.Close(); err != nil { | |
| 182 | - | log.Printf("[favicon] failed to close download file: %v", err) | |
| 183 | - | } | |
| 184 | - | }() | |
| 185 | - | defer func() { | |
| 186 | - | // Clean up temp download file if it still exists | |
| 187 | - | if _, err := os.Stat(tempDownloadFile); err == nil { | |
| 188 | - | if err := os.Remove(tempDownloadFile); err != nil { | |
| 189 | - | log.Printf("[favicon] failed to remove temp download file: %v", err) | |
| 190 | - | } | |
| 191 | - | } | |
| 192 | - | }() | |
| 193 | - | ||
| 194 | - | // Save raw favicon data | |
| 195 | - | if _, err := io.Copy(downloadFile, limitedBody); err != nil { | |
| 196 | - | return fmt.Errorf("failed to save favicon data: %w", err) | |
| 197 | - | } | |
| 198 | - | // Note: downloadFile will be closed by the defer above, no need to close explicitly | |
| 199 | - | ||
| 200 | - | // Detect if it's an ICO file and convert with ffmpeg if needed | |
| 201 | - | if err := fc.convertToPNG(tempDownloadFile, cachedPath, faviconURL); err != nil { | |
| 202 | - | return fmt.Errorf("failed to convert favicon: %w", err) | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | return nil | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | // convertToPNG converts a favicon file to PNG format | |
| 209 | - | func (fc *FaviconCache) convertToPNG(inputPath, outputPath, faviconURL string) error { | |
| 210 | - | // Try standard Go image decoder first for all formats (PNG, JPEG, GIF) | |
| 211 | - | if img, err := fc.tryStandardImageDecode(inputPath); err == nil { | |
| 212 | - | log.Printf("[favicon] Successfully decoded %s with standard library", faviconURL) | |
| 213 | - | ||
| 214 | - | // Apply dark theme improvements | |
| 215 | - | img = invertIfTooDark(img) | |
| 216 | - | ||
| 217 | - | // Save as PNG | |
| 218 | - | return fc.savePNG(img, outputPath) | |
| 219 | - | } | |
| 220 | - | ||
| 221 | - | // Standard decode failed, try go-ico decoder for ICO files | |
| 222 | - | log.Printf("[favicon] Standard decode failed for %s, trying go-ico decoder", faviconURL) | |
| 223 | - | return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL) | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | // tryStandardImageDecode attempts to decode image with Go standard library | |
| 227 | - | func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, error) { | |
| 228 | - | file, err := os.Open(filePath) | |
| 229 | - | if err != nil { | |
| 230 | - | return nil, err | |
| 231 | - | } | |
| 232 | - | defer func() { | |
| 233 | - | if err := file.Close(); err != nil { | |
| 234 | - | log.Printf("[favicon] failed to close file: %v", err) | |
| 235 | - | } | |
| 236 | - | }() | |
| 237 | - | ||
| 238 | - | img, _, err := image.Decode(file) | |
| 239 | - | return img, err | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | // savePNG saves an image as PNG | |
| 243 | - | func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error { | |
| 244 | - | tempFile := outputPath + ".tmp" | |
| 245 | - | file, err := os.Create(tempFile) | |
| 246 | - | if err != nil { | |
| 247 | - | return fmt.Errorf("failed to create temp file: %w", err) | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | // Track if we've already closed the file | |
| 251 | - | closed := false | |
| 252 | - | defer func() { | |
| 253 | - | if !closed { | |
| 254 | - | if err := file.Close(); err != nil { | |
| 255 | - | log.Printf("[favicon] failed to close file: %v", err) | |
| 256 | - | } | |
| 257 | - | } | |
| 258 | - | }() | |
| 259 | - | ||
| 260 | - | if err := png.Encode(file, img); err != nil { | |
| 261 | - | if removeErr := os.Remove(tempFile); removeErr != nil { | |
| 262 | - | log.Printf("[favicon] failed to remove temp file: %v", removeErr) | |
| 263 | - | } | |
| 264 | - | return fmt.Errorf("failed to encode PNG: %w", err) | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | // Close the file before renaming (required on Windows, good practice on all platforms) | |
| 268 | - | if err := file.Close(); err != nil { | |
| 269 | - | return fmt.Errorf("failed to close file before rename: %w", err) | |
| 270 | - | } | |
| 271 | - | closed = true | |
| 272 | - | ||
| 273 | - | // Atomic move | |
| 274 | - | if err := os.Rename(tempFile, outputPath); err != nil { | |
| 275 | - | os.Remove(tempFile) | |
| 276 | - | return fmt.Errorf("failed to move PNG to cache: %w", err) | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | return nil | |
| 280 | - | } | |
| 281 | - | ||
| 282 | - | // convertICOWithGoICO converts ICO files to PNG using go-ico library | |
| 283 | - | func (fc *FaviconCache) convertICOWithGoICO(inputPath, outputPath, faviconURL string) error { | |
| 284 | - | // Open the ICO file | |
| 285 | - | file, err := os.Open(inputPath) | |
| 286 | - | if err != nil { | |
| 287 | - | return fmt.Errorf("failed to open ICO file: %w", err) | |
| 288 | - | } | |
| 289 | - | defer file.Close() | |
| 290 | - | ||
| 291 | - | // Decode ICO file | |
| 292 | - | img, err := ico.Decode(file) | |
| 293 | - | if err != nil { | |
| 294 | - | return fmt.Errorf("failed to decode ICO file %s: %w", faviconURL, err) | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | log.Printf("[favicon] Successfully decoded ICO file %s with go-ico", faviconURL) | |
| 298 | - | ||
| 299 | - | // Apply dark theme improvements | |
| 300 | - | img = invertIfTooDark(img) | |
| 301 | - | ||
| 302 | - | // Save as PNG | |
| 303 | - | return fc.savePNG(img, outputPath) | |
| 304 | - | } | |
| 305 | - | ||
| 306 | - | // CleanOld removes cached favicons older than 30 days | |
| 307 | - | func (fc *FaviconCache) CleanOld() error { | |
| 308 | - | entries, err := os.ReadDir(fc.cacheDir) | |
| 309 | - | if err != nil { | |
| 310 | - | return err | |
| 311 | - | } | |
| 312 | - | ||
| 313 | - | cutoff := time.Now().Add(-30 * 24 * time.Hour) | |
| 314 | - | ||
| 315 | - | for _, entry := range entries { | |
| 316 | - | if entry.IsDir() { | |
| 317 | - | continue | |
| 318 | - | } | |
| 319 | - | ||
| 320 | - | info, err := entry.Info() | |
| 321 | - | if err != nil { | |
| 322 | - | continue | |
| 323 | - | } | |
| 324 | - | ||
| 325 | - | if info.ModTime().Before(cutoff) { | |
| 326 | - | os.Remove(filepath.Join(fc.cacheDir, entry.Name())) | |
| 327 | - | } | |
| 328 | - | } | |
| 329 | - | ||
| 330 | - | return nil | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | // invertIfTooDark inverts icons that are too dark for dark theme visibility | |
| 334 | - | // Only inverts truly monochrome dark icons, preserves colorful icons | |
| 335 | - | func invertIfTooDark(img image.Image) image.Image { | |
| 336 | - | bounds := img.Bounds() | |
| 337 | - | ||
| 338 | - | // Sample pixels to calculate statistics | |
| 339 | - | var totalR, totalG, totalB uint32 | |
| 340 | - | var pixelCount uint32 | |
| 341 | - | var colorVariance uint32 | |
| 342 | - | var transparentPixels uint32 | |
| 343 | - | ||
| 344 | - | // Sample every 4th pixel for performance | |
| 345 | - | for y := bounds.Min.Y; y < bounds.Max.Y; y += 4 { | |
| 346 | - | for x := bounds.Min.X; x < bounds.Max.X; x += 4 { | |
| 347 | - | r, g, b, a := img.At(x, y).RGBA() | |
| 348 | - | ||
| 349 | - | if a == 0 { | |
| 350 | - | transparentPixels++ | |
| 351 | - | continue | |
| 352 | - | } | |
| 353 | - | ||
| 354 | - | if a > 0 { // Only count non-transparent pixels | |
| 355 | - | totalR += r | |
| 356 | - | totalG += g | |
| 357 | - | totalB += b | |
| 358 | - | pixelCount++ | |
| 359 | - | ||
| 360 | - | // Calculate color variance to detect colorful vs monochrome icons | |
| 361 | - | // High variance = colorful (don't invert), low variance = monochrome | |
| 362 | - | rNorm := r >> 8 // Convert to 0-255 range | |
| 363 | - | gNorm := g >> 8 | |
| 364 | - | bNorm := b >> 8 | |
| 365 | - | ||
| 366 | - | // Simple color variance: difference between max and min color channels | |
| 367 | - | maxColor := rNorm | |
| 368 | - | if gNorm > maxColor { | |
| 369 | - | maxColor = gNorm | |
| 370 | - | } | |
| 371 | - | if bNorm > maxColor { | |
| 372 | - | maxColor = bNorm | |
| 373 | - | } | |
| 374 | - | ||
| 375 | - | minColor := rNorm | |
| 376 | - | if gNorm < minColor { | |
| 377 | - | minColor = gNorm | |
| 378 | - | } | |
| 379 | - | if bNorm < minColor { | |
| 380 | - | minColor = bNorm | |
| 381 | - | } | |
| 382 | - | ||
| 383 | - | colorVariance += maxColor - minColor | |
| 384 | - | } | |
| 385 | - | } | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | if pixelCount == 0 { | |
| 389 | - | return img // All transparent | |
| 390 | - | } | |
| 391 | - | ||
| 392 | - | // Calculate average brightness (0-65535 range) | |
| 393 | - | avgR := totalR / pixelCount | |
| 394 | - | avgG := totalG / pixelCount | |
| 395 | - | avgB := totalB / pixelCount | |
| 396 | - | ||
| 397 | - | // Calculate perceived brightness using standard luminance formula | |
| 398 | - | brightness := (299*avgR + 587*avgG + 114*avgB) / 1000 | |
| 399 | - | ||
| 400 | - | // Calculate average color variance (0-255 range) | |
| 401 | - | avgColorVariance := colorVariance / pixelCount | |
| 402 | - | ||
| 403 | - | // Calculate transparency ratio | |
| 404 | - | totalPixels := pixelCount + transparentPixels | |
| 405 | - | transparencyRatio := float64(transparentPixels) / float64(totalPixels) | |
| 406 | - | ||
| 407 | - | // Only invert if: | |
| 408 | - | // 1. Icon is very dark (brightness < 20000, low brightness) | |
| 409 | - | // 2. AND icon is mostly monochrome (low color variance < 20) | |
| 410 | - | // 3. AND icon is not mostly transparent (< 80% transparent) | |
| 411 | - | // This makes dark monochrome icons visible on dark themes while preserving colorful icons | |
| 412 | - | ||
| 413 | - | shouldInvert := brightness < 20000 && avgColorVariance < 20 && transparencyRatio < 0.8 | |
| 414 | - | ||
| 415 | - | log.Printf("[favicon] Brightness analysis: brightness=%d, colorVariance=%d, transparency=%.2f, shouldInvert=%t", | |
| 416 | - | brightness, avgColorVariance, transparencyRatio, shouldInvert) | |
| 417 | - | ||
| 418 | - | if shouldInvert { | |
| 419 | - | log.Printf("[favicon] Inverting dark monochrome icon to light for better dark theme visibility") | |
| 420 | - | return invertImage(img) | |
| 421 | - | } | |
| 422 | - | ||
| 423 | - | return img | |
| 424 | - | } | |
| 425 | - | ||
| 426 | - | // invertImage inverts the colors of an image with edge smoothing | |
| 427 | - | func invertImage(img image.Image) image.Image { | |
| 428 | - | bounds := img.Bounds() | |
| 429 | - | inverted := image.NewRGBA(bounds) | |
| 430 | - | ||
| 431 | - | for y := bounds.Min.Y; y < bounds.Max.Y; y++ { | |
| 432 | - | for x := bounds.Min.X; x < bounds.Max.X; x++ { | |
| 433 | - | r, g, b, a := img.At(x, y).RGBA() | |
| 434 | - | ||
| 435 | - | // Skip fully transparent pixels to avoid noise | |
| 436 | - | if a == 0 { | |
| 437 | - | inverted.Set(x, y, color.RGBA{A: 0}) | |
| 438 | - | continue | |
| 439 | - | } | |
| 440 | - | ||
| 441 | - | // Convert to 8-bit for easier processing | |
| 442 | - | r8 := uint8(r >> 8) | |
| 443 | - | g8 := uint8(g >> 8) | |
| 444 | - | b8 := uint8(b >> 8) | |
| 445 | - | a8 := uint8(a >> 8) | |
| 446 | - | ||
| 447 | - | // Invert RGB but preserve alpha | |
| 448 | - | newR := 255 - r8 | |
| 449 | - | newG := 255 - g8 | |
| 450 | - | newB := 255 - b8 | |
| 451 | - | ||
| 452 | - | // For semi-transparent pixels, blend the inversion more gently | |
| 453 | - | // This reduces edge noise by making transitions smoother | |
| 454 | - | if a8 < 255 { | |
| 455 | - | // Reduce inversion intensity for semi-transparent pixels | |
| 456 | - | blendFactor := float64(a8) / 255.0 | |
| 457 | - | newR = uint8(float64(r8) + (float64(newR-r8) * blendFactor)) | |
| 458 | - | newG = uint8(float64(g8) + (float64(newG-g8) * blendFactor)) | |
| 459 | - | newB = uint8(float64(b8) + (float64(newB-b8) * blendFactor)) | |
| 460 | - | } | |
| 461 | - | ||
| 462 | - | inverted.Set(x, y, color.RGBA{ | |
| 463 | - | R: newR, | |
| 464 | - | G: newG, | |
| 465 | - | B: newB, | |
| 466 | - | A: a8, | |
| 467 | - | }) | |
| 468 | - | } | |
| 469 | - | } | |
| 470 | - | ||
| 471 | - | return inverted | |
| 472 | - | } | |
| @@ -1,472 +0,0 @@ | |||||
| 1 | - | package cache | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "crypto/md5" | |||
| 5 | - | "fmt" | |||
| 6 | - | "image" | |||
| 7 | - | "image/color" | |||
| 8 | - | _ "image/gif" // Register GIF decoder | |||
| 9 | - | _ "image/jpeg" // Register JPEG decoder | |||
| 10 | - | "image/png" | |||
| 11 | - | "io" | |||
| 12 | - | "log" | |||
| 13 | - | "net/http" | |||
| 14 | - | "os" | |||
| 15 | - | "path/filepath" | |||
| 16 | - | "strings" | |||
| 17 | - | "sync" | |||
| 18 | - | "time" | |||
| 19 | - | ||||
| 20 | - | "github.com/bnema/dumber/internal/config" | |||
| 21 | - | "github.com/sergeymakinen/go-ico" | |||
| 22 | - | ) | |||
| 23 | - | ||||
| 24 | - | // FaviconCache manages favicon caching to local files | |||
| 25 | - | type FaviconCache struct { | |||
| 26 | - | cacheDir string | |||
| 27 | - | downloadMutex sync.Mutex // Prevents concurrent downloads of the same favicon | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | // NewFaviconCache creates a new favicon cache | |||
| 31 | - | func NewFaviconCache() (*FaviconCache, error) { | |||
| 32 | - | dataDir, err := config.GetDataDir() | |||
| 33 | - | if err != nil { | |||
| 34 | - | return nil, fmt.Errorf("failed to get data directory: %w", err) | |||
| 35 | - | } | |||
| 36 | - | ||||
| 37 | - | cacheDir := filepath.Join(dataDir, "favicons") | |||
| 38 | - | if err := os.MkdirAll(cacheDir, 0755); err != nil { | |||
| 39 | - | return nil, fmt.Errorf("failed to create favicon cache directory: %w", err) | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | return &FaviconCache{ | |||
| 43 | - | cacheDir: cacheDir, | |||
| 44 | - | }, nil | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | // GetCachedPath returns the local file path for a favicon URL if cached, empty string otherwise | |||
| 48 | - | func (fc *FaviconCache) GetCachedPath(faviconURL string) string { | |||
| 49 | - | if faviconURL == "" { | |||
| 50 | - | return "" | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | filename := fc.getFilename(faviconURL) | |||
| 54 | - | cachedPath := filepath.Join(fc.cacheDir, filename) | |||
| 55 | - | ||||
| 56 | - | // Check if file exists and is not too old (7 days) | |||
| 57 | - | if info, err := os.Stat(cachedPath); err == nil { | |||
| 58 | - | if time.Since(info.ModTime()) < 7*24*time.Hour { | |||
| 59 | - | return cachedPath | |||
| 60 | - | } | |||
| 61 | - | } | |||
| 62 | - | ||||
| 63 | - | return "" | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | // CacheAsync downloads and caches a favicon asynchronously | |||
| 67 | - | func (fc *FaviconCache) CacheAsync(faviconURL string) { | |||
| 68 | - | if faviconURL == "" { | |||
| 69 | - | return | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | - | go func() { | |||
| 73 | - | if err := fc.downloadFavicon(faviconURL); err != nil { | |||
| 74 | - | // Silently ignore errors in background download | |||
| 75 | - | } | |||
| 76 | - | }() | |||
| 77 | - | } | |||
| 78 | - | ||||
| 79 | - | // CachePNGData caches PNG favicon data directly for a URL | |||
| 80 | - | func (fc *FaviconCache) CachePNGData(pageURL string, pngData []byte) error { | |||
| 81 | - | if pageURL == "" || len(pngData) == 0 { | |||
| 82 | - | return fmt.Errorf("invalid input: empty URL or data") | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | filename := fc.getFilename(pageURL) | |||
| 86 | - | cachedPath := filepath.Join(fc.cacheDir, filename) | |||
| 87 | - | ||||
| 88 | - | // Write PNG data directly to cache | |||
| 89 | - | return os.WriteFile(cachedPath, pngData, 0644) | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | // GetCachedPathForURL returns the cached path for a page URL (not favicon URL) | |||
| 93 | - | func (fc *FaviconCache) GetCachedPathForURL(pageURL string) string { | |||
| 94 | - | if pageURL == "" { | |||
| 95 | - | return "" | |||
| 96 | - | } | |||
| 97 | - | ||||
| 98 | - | filename := fc.getFilename(pageURL) | |||
| 99 | - | cachedPath := filepath.Join(fc.cacheDir, filename) | |||
| 100 | - | ||||
| 101 | - | // Check if file exists and is not too old (7 days) | |||
| 102 | - | if info, err := os.Stat(cachedPath); err == nil { | |||
| 103 | - | if time.Since(info.ModTime()) < 7*24*time.Hour { | |||
| 104 | - | return cachedPath | |||
| 105 | - | } | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | - | return "" | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | // getFilename generates a safe filename for a favicon URL - always PNG for fuzzel compatibility | |||
| 112 | - | func (fc *FaviconCache) getFilename(faviconURL string) string { | |||
| 113 | - | hash := fmt.Sprintf("%x", md5.Sum([]byte(faviconURL))) | |||
| 114 | - | return hash + ".png" | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | - | // downloadFavicon downloads a favicon and saves it to cache | |||
| 118 | - | func (fc *FaviconCache) downloadFavicon(faviconURL string) error { | |||
| 119 | - | // Skip SVG files - we can't convert them to PNG without additional dependencies | |||
| 120 | - | if strings.HasSuffix(strings.ToLower(faviconURL), ".svg") { | |||
| 121 | - | log.Printf("[favicon] Skipping SVG favicon: %s", faviconURL) | |||
| 122 | - | return nil | |||
| 123 | - | } | |||
| 124 | - | ||||
| 125 | - | filename := fc.getFilename(faviconURL) | |||
| 126 | - | cachedPath := filepath.Join(fc.cacheDir, filename) | |||
| 127 | - | ||||
| 128 | - | // Lock to prevent concurrent downloads of the same favicon | |||
| 129 | - | // This prevents race conditions on temp files and duplicate downloads | |||
| 130 | - | fc.downloadMutex.Lock() | |||
| 131 | - | defer fc.downloadMutex.Unlock() | |||
| 132 | - | ||||
| 133 | - | // Double-check if already exists after acquiring lock | |||
| 134 | - | // Another goroutine might have downloaded it while we were waiting | |||
| 135 | - | if info, err := os.Stat(cachedPath); err == nil { | |||
| 136 | - | if time.Since(info.ModTime()) < 7*24*time.Hour { | |||
| 137 | - | return nil | |||
| 138 | - | } | |||
| 139 | - | } | |||
| 140 | - | ||||
| 141 | - | // Create HTTP client with timeout and redirect handling | |||
| 142 | - | client := &http.Client{ | |||
| 143 | - | Timeout: 10 * time.Second, | |||
| 144 | - | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |||
| 145 | - | if len(via) >= 5 { | |||
| 146 | - | return fmt.Errorf("too many redirects") | |||
| 147 | - | } | |||
| 148 | - | return nil | |||
| 149 | - | }, | |||
| 150 | - | } | |||
| 151 | - | ||||
| 152 | - | resp, err := client.Get(faviconURL) | |||
| 153 | - | if err != nil { | |||
| 154 | - | return fmt.Errorf("failed to download favicon: %w", err) | |||
| 155 | - | } | |||
| 156 | - | defer func() { | |||
| 157 | - | if err := resp.Body.Close(); err != nil { | |||
| 158 | - | log.Printf("[favicon] failed to close response body: %v", err) | |||
| 159 | - | } | |||
| 160 | - | }() | |||
| 161 | - | ||||
| 162 | - | // Log redirects for debugging | |||
| 163 | - | if resp.Request.URL.String() != faviconURL { | |||
| 164 | - | log.Printf("[favicon] Redirected from %s to %s", faviconURL, resp.Request.URL.String()) | |||
| 165 | - | } | |||
| 166 | - | ||||
| 167 | - | if resp.StatusCode != http.StatusOK { | |||
| 168 | - | return fmt.Errorf("failed to download favicon: status %d", resp.StatusCode) | |||
| 169 | - | } | |||
| 170 | - | ||||
| 171 | - | // Limit response body size | |||
| 172 | - | limitedBody := io.LimitReader(resp.Body, 1024*1024) // 1MB max | |||
| 173 | - | ||||
| 174 | - | // Download to temporary file first | |||
| 175 | - | tempDownloadFile := cachedPath + ".download" | |||
| 176 | - | downloadFile, err := os.Create(tempDownloadFile) | |||
| 177 | - | if err != nil { | |||
| 178 | - | return fmt.Errorf("failed to create download temp file: %w", err) | |||
| 179 | - | } | |||
| 180 | - | defer func() { | |||
| 181 | - | if err := downloadFile.Close(); err != nil { | |||
| 182 | - | log.Printf("[favicon] failed to close download file: %v", err) | |||
| 183 | - | } | |||
| 184 | - | }() | |||
| 185 | - | defer func() { | |||
| 186 | - | // Clean up temp download file if it still exists | |||
| 187 | - | if _, err := os.Stat(tempDownloadFile); err == nil { | |||
| 188 | - | if err := os.Remove(tempDownloadFile); err != nil { | |||
| 189 | - | log.Printf("[favicon] failed to remove temp download file: %v", err) | |||
| 190 | - | } | |||
| 191 | - | } | |||
| 192 | - | }() | |||
| 193 | - | ||||
| 194 | - | // Save raw favicon data | |||
| 195 | - | if _, err := io.Copy(downloadFile, limitedBody); err != nil { | |||
| 196 | - | return fmt.Errorf("failed to save favicon data: %w", err) | |||
| 197 | - | } | |||
| 198 | - | // Note: downloadFile will be closed by the defer above, no need to close explicitly | |||
| 199 | - | ||||
| 200 | - | // Detect if it's an ICO file and convert with ffmpeg if needed | |||
| 201 | - | if err := fc.convertToPNG(tempDownloadFile, cachedPath, faviconURL); err != nil { | |||
| 202 | - | return fmt.Errorf("failed to convert favicon: %w", err) | |||
| 203 | - | } | |||
| 204 | - | ||||
| 205 | - | return nil | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | // convertToPNG converts a favicon file to PNG format | |||
| 209 | - | func (fc *FaviconCache) convertToPNG(inputPath, outputPath, faviconURL string) error { | |||
| 210 | - | // Try standard Go image decoder first for all formats (PNG, JPEG, GIF) | |||
| 211 | - | if img, err := fc.tryStandardImageDecode(inputPath); err == nil { | |||
| 212 | - | log.Printf("[favicon] Successfully decoded %s with standard library", faviconURL) | |||
| 213 | - | ||||
| 214 | - | // Apply dark theme improvements | |||
| 215 | - | img = invertIfTooDark(img) | |||
| 216 | - | ||||
| 217 | - | // Save as PNG | |||
| 218 | - | return fc.savePNG(img, outputPath) | |||
| 219 | - | } | |||
| 220 | - | ||||
| 221 | - | // Standard decode failed, try go-ico decoder for ICO files | |||
| 222 | - | log.Printf("[favicon] Standard decode failed for %s, trying go-ico decoder", faviconURL) | |||
| 223 | - | return fc.convertICOWithGoICO(inputPath, outputPath, faviconURL) | |||
| 224 | - | } | |||
| 225 | - | ||||
| 226 | - | // tryStandardImageDecode attempts to decode image with Go standard library | |||
| 227 | - | func (fc *FaviconCache) tryStandardImageDecode(filePath string) (image.Image, error) { | |||
| 228 | - | file, err := os.Open(filePath) | |||
| 229 | - | if err != nil { | |||
| 230 | - | return nil, err | |||
| 231 | - | } | |||
| 232 | - | defer func() { | |||
| 233 | - | if err := file.Close(); err != nil { | |||
| 234 | - | log.Printf("[favicon] failed to close file: %v", err) | |||
| 235 | - | } | |||
| 236 | - | }() | |||
| 237 | - | ||||
| 238 | - | img, _, err := image.Decode(file) | |||
| 239 | - | return img, err | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | - | // savePNG saves an image as PNG | |||
| 243 | - | func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error { | |||
| 244 | - | tempFile := outputPath + ".tmp" | |||
| 245 | - | file, err := os.Create(tempFile) | |||
| 246 | - | if err != nil { | |||
| 247 | - | return fmt.Errorf("failed to create temp file: %w", err) | |||
| 248 | - | } | |||
| 249 | - | ||||
| 250 | - | // Track if we've already closed the file | |||
| 251 | - | closed := false | |||
| 252 | - | defer func() { | |||
| 253 | - | if !closed { | |||
| 254 | - | if err := file.Close(); err != nil { | |||
| 255 | - | log.Printf("[favicon] failed to close file: %v", err) | |||
| 256 | - | } | |||
| 257 | - | } | |||
| 258 | - | }() | |||
| 259 | - | ||||
| 260 | - | if err := png.Encode(file, img); err != nil { | |||
| 261 | - | if removeErr := os.Remove(tempFile); removeErr != nil { | |||
| 262 | - | log.Printf("[favicon] failed to remove temp file: %v", removeErr) | |||
| 263 | - | } | |||
| 264 | - | return fmt.Errorf("failed to encode PNG: %w", err) | |||
| 265 | - | } | |||
| 266 | - | ||||
| 267 | - | // Close the file before renaming (required on Windows, good practice on all platforms) | |||
| 268 | - | if err := file.Close(); err != nil { | |||
| 269 | - | return fmt.Errorf("failed to close file before rename: %w", err) | |||
| 270 | - | } | |||
| 271 | - | closed = true | |||
| 272 | - | ||||
| 273 | - | // Atomic move | |||
| 274 | - | if err := os.Rename(tempFile, outputPath); err != nil { | |||
| 275 | - | os.Remove(tempFile) | |||
| 276 | - | return fmt.Errorf("failed to move PNG to cache: %w", err) | |||
| 277 | - | } | |||
| 278 | - | ||||
| 279 | - | return nil | |||
| 280 | - | } | |||
| 281 | - | ||||
| 282 | - | // convertICOWithGoICO converts ICO files to PNG using go-ico library | |||
| 283 | - | func (fc *FaviconCache) convertICOWithGoICO(inputPath, outputPath, faviconURL string) error { | |||
| 284 | - | // Open the ICO file | |||
| 285 | - | file, err := os.Open(inputPath) | |||
| 286 | - | if err != nil { | |||
| 287 | - | return fmt.Errorf("failed to open ICO file: %w", err) | |||
| 288 | - | } | |||
| 289 | - | defer file.Close() | |||
| 290 | - | ||||
| 291 | - | // Decode ICO file | |||
| 292 | - | img, err := ico.Decode(file) | |||
| 293 | - | if err != nil { | |||
| 294 | - | return fmt.Errorf("failed to decode ICO file %s: %w", faviconURL, err) | |||
| 295 | - | } | |||
| 296 | - | ||||
| 297 | - | log.Printf("[favicon] Successfully decoded ICO file %s with go-ico", faviconURL) | |||
| 298 | - | ||||
| 299 | - | // Apply dark theme improvements | |||
| 300 | - | img = invertIfTooDark(img) | |||
| 301 | - | ||||
| 302 | - | // Save as PNG | |||
| 303 | - | return fc.savePNG(img, outputPath) | |||
| 304 | - | } | |||
| 305 | - | ||||
| 306 | - | // CleanOld removes cached favicons older than 30 days | |||
| 307 | - | func (fc *FaviconCache) CleanOld() error { | |||
| 308 | - | entries, err := os.ReadDir(fc.cacheDir) | |||
| 309 | - | if err != nil { | |||
| 310 | - | return err | |||
| 311 | - | } | |||
| 312 | - | ||||
| 313 | - | cutoff := time.Now().Add(-30 * 24 * time.Hour) | |||
| 314 | - | ||||
| 315 | - | for _, entry := range entries { | |||
| 316 | - | if entry.IsDir() { | |||
| 317 | - | continue | |||
| 318 | - | } | |||
| 319 | - | ||||
| 320 | - | info, err := entry.Info() | |||
| 321 | - | if err != nil { | |||
| 322 | - | continue | |||
| 323 | - | } | |||
| 324 | - | ||||
| 325 | - | if info.ModTime().Before(cutoff) { | |||
| 326 | - | os.Remove(filepath.Join(fc.cacheDir, entry.Name())) | |||
| 327 | - | } | |||
| 328 | - | } | |||
| 329 | - | ||||
| 330 | - | return nil | |||
| 331 | - | } | |||
| 332 | - | ||||
| 333 | - | // invertIfTooDark inverts icons that are too dark for dark theme visibility | |||
| 334 | - | // Only inverts truly monochrome dark icons, preserves colorful icons | |||
| 335 | - | func invertIfTooDark(img image.Image) image.Image { | |||
| 336 | - | bounds := img.Bounds() | |||
| 337 | - | ||||
| 338 | - | // Sample pixels to calculate statistics | |||
| 339 | - | var totalR, totalG, totalB uint32 | |||
| 340 | - | var pixelCount uint32 | |||
| 341 | - | var colorVariance uint32 | |||
| 342 | - | var transparentPixels uint32 | |||
| 343 | - | ||||
| 344 | - | // Sample every 4th pixel for performance | |||
| 345 | - | for y := bounds.Min.Y; y < bounds.Max.Y; y += 4 { | |||
| 346 | - | for x := bounds.Min.X; x < bounds.Max.X; x += 4 { | |||
| 347 | - | r, g, b, a := img.At(x, y).RGBA() | |||
| 348 | - | ||||
| 349 | - | if a == 0 { | |||
| 350 | - | transparentPixels++ | |||
| 351 | - | continue | |||
| 352 | - | } | |||
| 353 | - | ||||
| 354 | - | if a > 0 { // Only count non-transparent pixels | |||
| 355 | - | totalR += r | |||
| 356 | - | totalG += g | |||
| 357 | - | totalB += b | |||
| 358 | - | pixelCount++ | |||
| 359 | - | ||||
| 360 | - | // Calculate color variance to detect colorful vs monochrome icons | |||
| 361 | - | // High variance = colorful (don't invert), low variance = monochrome | |||
| 362 | - | rNorm := r >> 8 // Convert to 0-255 range | |||
| 363 | - | gNorm := g >> 8 | |||
| 364 | - | bNorm := b >> 8 | |||
| 365 | - | ||||
| 366 | - | // Simple color variance: difference between max and min color channels | |||
| 367 | - | maxColor := rNorm | |||
| 368 | - | if gNorm > maxColor { | |||
| 369 | - | maxColor = gNorm | |||
| 370 | - | } | |||
| 371 | - | if bNorm > maxColor { | |||
| 372 | - | maxColor = bNorm | |||
| 373 | - | } | |||
| 374 | - | ||||
| 375 | - | minColor := rNorm | |||
| 376 | - | if gNorm < minColor { | |||
| 377 | - | minColor = gNorm | |||
| 378 | - | } | |||
| 379 | - | if bNorm < minColor { | |||
| 380 | - | minColor = bNorm | |||
| 381 | - | } | |||
| 382 | - | ||||
| 383 | - | colorVariance += maxColor - minColor | |||
| 384 | - | } | |||
| 385 | - | } | |||
| 386 | - | } | |||
| 387 | - | ||||
| 388 | - | if pixelCount == 0 { | |||
| 389 | - | return img // All transparent | |||
| 390 | - | } | |||
| 391 | - | ||||
| 392 | - | // Calculate average brightness (0-65535 range) | |||
| 393 | - | avgR := totalR / pixelCount | |||
| 394 | - | avgG := totalG / pixelCount | |||
| 395 | - | avgB := totalB / pixelCount | |||
| 396 | - | ||||
| 397 | - | // Calculate perceived brightness using standard luminance formula | |||
| 398 | - | brightness := (299*avgR + 587*avgG + 114*avgB) / 1000 | |||
| 399 | - | ||||
| 400 | - | // Calculate average color variance (0-255 range) | |||
| 401 | - | avgColorVariance := colorVariance / pixelCount | |||
| 402 | - | ||||
| 403 | - | // Calculate transparency ratio | |||
| 404 | - | totalPixels := pixelCount + transparentPixels | |||
| 405 | - | transparencyRatio := float64(transparentPixels) / float64(totalPixels) | |||
| 406 | - | ||||
| 407 | - | // Only invert if: | |||
| 408 | - | // 1. Icon is very dark (brightness < 20000, low brightness) | |||
| 409 | - | // 2. AND icon is mostly monochrome (low color variance < 20) | |||
| 410 | - | // 3. AND icon is not mostly transparent (< 80% transparent) | |||
| 411 | - | // This makes dark monochrome icons visible on dark themes while preserving colorful icons | |||
| 412 | - | ||||
| 413 | - | shouldInvert := brightness < 20000 && avgColorVariance < 20 && transparencyRatio < 0.8 | |||
| 414 | - | ||||
| 415 | - | log.Printf("[favicon] Brightness analysis: brightness=%d, colorVariance=%d, transparency=%.2f, shouldInvert=%t", | |||
| 416 | - | brightness, avgColorVariance, transparencyRatio, shouldInvert) | |||
| 417 | - | ||||
| 418 | - | if shouldInvert { | |||
| 419 | - | log.Printf("[favicon] Inverting dark monochrome icon to light for better dark theme visibility") | |||
| 420 | - | return invertImage(img) | |||
| 421 | - | } | |||
| 422 | - | ||||
| 423 | - | return img | |||
| 424 | - | } | |||
| 425 | - | ||||
| 426 | - | // invertImage inverts the colors of an image with edge smoothing | |||
| 427 | - | func invertImage(img image.Image) image.Image { | |||
| 428 | - | bounds := img.Bounds() | |||
| 429 | - | inverted := image.NewRGBA(bounds) | |||
| 430 | - | ||||
| 431 | - | for y := bounds.Min.Y; y < bounds.Max.Y; y++ { | |||
| 432 | - | for x := bounds.Min.X; x < bounds.Max.X; x++ { | |||
| 433 | - | r, g, b, a := img.At(x, y).RGBA() | |||
| 434 | - | ||||
| 435 | - | // Skip fully transparent pixels to avoid noise | |||
| 436 | - | if a == 0 { | |||
| 437 | - | inverted.Set(x, y, color.RGBA{A: 0}) | |||
| 438 | - | continue | |||
| 439 | - | } | |||
| 440 | - | ||||
| 441 | - | // Convert to 8-bit for easier processing | |||
| 442 | - | r8 := uint8(r >> 8) | |||
| 443 | - | g8 := uint8(g >> 8) | |||
| 444 | - | b8 := uint8(b >> 8) | |||
| 445 | - | a8 := uint8(a >> 8) | |||
| 446 | - | ||||
| 447 | - | // Invert RGB but preserve alpha | |||
| 448 | - | newR := 255 - r8 | |||
| 449 | - | newG := 255 - g8 | |||
| 450 | - | newB := 255 - b8 | |||
| 451 | - | ||||
| 452 | - | // For semi-transparent pixels, blend the inversion more gently | |||
| 453 | - | // This reduces edge noise by making transitions smoother | |||
| 454 | - | if a8 < 255 { | |||
| 455 | - | // Reduce inversion intensity for semi-transparent pixels | |||
| 456 | - | blendFactor := float64(a8) / 255.0 | |||
| 457 | - | newR = uint8(float64(r8) + (float64(newR-r8) * blendFactor)) | |||
| 458 | - | newG = uint8(float64(g8) + (float64(newG-g8) * blendFactor)) | |||
| 459 | - | newB = uint8(float64(b8) + (float64(newB-b8) * blendFactor)) | |||
| 460 | - | } | |||
| 461 | - | ||||
| 462 | - | inverted.Set(x, y, color.RGBA{ | |||
| 463 | - | R: newR, | |||
| 464 | - | G: newG, | |||
| 465 | - | B: newB, | |||
| 466 | - | A: a8, | |||
| 467 | - | }) | |||
| 468 | - | } | |||
| 469 | - | } | |||
| 470 | - | ||||
| 471 | - | return inverted | |||
| 472 | - | } | |||
M internal/cli/browse.go +0 -7
| @@ -9,7 +9,6 @@ import ( | |||
| 9 | 9 | "os/exec" | |
| 10 | 10 | "strings" | |
| 11 | 11 | ||
| 12 | - | "github.com/bnema/dumber/internal/cache" | |
| 13 | 12 | "github.com/bnema/dumber/internal/config" | |
| 14 | 13 | ||
| 15 | 14 | "github.com/spf13/cobra" | |
| @@ -161,12 +160,6 @@ func updateFavicon(ctx context.Context, cli *CLI, pageURL string) { | |||
| 161 | 160 | // Update in database using the new sqlc-generated method | |
| 162 | 161 | faviconNullString := sql.NullString{String: faviconURL, Valid: true} | |
| 163 | 162 | if err := cli.Queries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil { | |
| 164 | - | // Silently fail - favicon is not critical | |
| 165 | 163 | return | |
| 166 | 164 | } | |
| 167 | - | ||
| 168 | - | // Also cache the favicon for dmenu use | |
| 169 | - | if faviconCache, err := cache.NewFaviconCache(); err == nil { | |
| 170 | - | faviconCache.CacheAsync(faviconURL) | |
| 171 | - | } | |
| 172 | 165 | } | |
| @@ -9,7 +9,6 @@ import ( | |||||
| 9 | "os/exec" | 9 | "os/exec" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | 11 | ||||
| 12 | - | "github.com/bnema/dumber/internal/cache" | |||
| 13 | "github.com/bnema/dumber/internal/config" | 12 | "github.com/bnema/dumber/internal/config" | ||
| 14 | 13 | ||||
| 15 | "github.com/spf13/cobra" | 14 | "github.com/spf13/cobra" | ||
| @@ -161,12 +160,6 @@ func updateFavicon(ctx context.Context, cli *CLI, pageURL string) { | |||||
| 161 | // Update in database using the new sqlc-generated method | 160 | // Update in database using the new sqlc-generated method | ||
| 162 | faviconNullString := sql.NullString{String: faviconURL, Valid: true} | 161 | faviconNullString := sql.NullString{String: faviconURL, Valid: true} | ||
| 163 | if err := cli.Queries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil { | 162 | if err := cli.Queries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil { | ||
| 164 | - | // Silently fail - favicon is not critical | |||
| 165 | return | 163 | return | ||
| 166 | } | 164 | } | ||
| 167 | - | ||||
| 168 | - | // Also cache the favicon for dmenu use | |||
| 169 | - | if faviconCache, err := cache.NewFaviconCache(); err == nil { | |||
| 170 | - | faviconCache.CacheAsync(faviconURL) | |||
| 171 | - | } | |||
| 172 | } | 165 | } | ||
M internal/cli/dmenu.go +21 -18
| @@ -8,8 +8,11 @@ import ( | |||
| 8 | 8 | "os" | |
| 9 | 9 | "sort" | |
| 10 | 10 | "strings" | |
| 11 | + | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/dumber/internal/cache" | |
| 14 | + | "github.com/bnema/dumber/internal/config" | |
| 15 | + | "github.com/bnema/dumber/internal/services" | |
| 13 | 16 | "github.com/spf13/cobra" | |
| 14 | 17 | ) | |
| 15 | 18 | ||
| @@ -63,11 +66,10 @@ func generateOptions(cli *CLI) error { | |||
| 63 | 66 | cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries | |
| 64 | 67 | cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig) | |
| 65 | 68 | ||
| 66 | - | // Initialize favicon cache | |
| 67 | - | faviconCache, err := cache.NewFaviconCache() | |
| 69 | + | // Get data directory for favicon exports | |
| 70 | + | dataDir, err := config.GetDataDir() | |
| 68 | 71 | if err != nil { | |
| 69 | - | // Continue without favicon cache if it fails | |
| 70 | - | faviconCache = nil | |
| 72 | + | dataDir = "" // Continue without favicons if data dir fails | |
| 71 | 73 | } | |
| 72 | 74 | ||
| 73 | 75 | // Get top entries from cache (this is blazingly fast!) | |
| @@ -139,7 +141,7 @@ func generateOptions(cli *CLI) error { | |||
| 139 | 141 | ||
| 140 | 142 | // Output options to stdout with icon specifications | |
| 141 | 143 | for _, option := range options { | |
| 142 | - | iconName := getIconName(option, faviconCache) | |
| 144 | + | iconName := getIconName(option, dataDir) | |
| 143 | 145 | if iconName != "" { | |
| 144 | 146 | fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName) | |
| 145 | 147 | } else { | |
| @@ -157,11 +159,10 @@ func generateOptions(cli *CLI) error { | |||
| 157 | 159 | func generateOptionsFallback(cli *CLI) error { | |
| 158 | 160 | ctx := context.Background() | |
| 159 | 161 | ||
| 160 | - | // Initialize favicon cache | |
| 161 | - | faviconCache, err := cache.NewFaviconCache() | |
| 162 | + | // Get data directory for favicon exports | |
| 163 | + | dataDir, err := config.GetDataDir() | |
| 162 | 164 | if err != nil { | |
| 163 | - | // Continue without favicon cache if it fails | |
| 164 | - | faviconCache = nil | |
| 165 | + | dataDir = "" // Continue without favicons if data dir fails | |
| 165 | 166 | } | |
| 166 | 167 | ||
| 167 | 168 | const maxHistoryEntries = 50 | |
| @@ -240,7 +241,7 @@ func generateOptionsFallback(cli *CLI) error { | |||
| 240 | 241 | ||
| 241 | 242 | // Output options to stdout with icon specifications | |
| 242 | 243 | for _, option := range options { | |
| 243 | - | iconName := getIconName(option, faviconCache) | |
| 244 | + | iconName := getIconName(option, dataDir) | |
| 244 | 245 | if iconName != "" { | |
| 245 | 246 | fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName) | |
| 246 | 247 | } else { | |
| @@ -344,19 +345,21 @@ func parseSelection(selection string) string { | |||
| 344 | 345 | } | |
| 345 | 346 | ||
| 346 | 347 | // getIconName determines the appropriate icon name for a dmenu option | |
| 347 | - | func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string { | |
| 348 | + | func getIconName(option DmenuOption, dataDir string) string { | |
| 348 | 349 | if option.Type != historyType { | |
| 349 | 350 | return "" | |
| 350 | 351 | } | |
| 351 | 352 | ||
| 352 | - | // Only use cached favicons - no system theme fallbacks for consistent sizing | |
| 353 | - | if option.FaviconURL != "" && faviconCache != nil { | |
| 354 | - | cachedPath := faviconCache.GetCachedPath(option.FaviconURL) | |
| 355 | - | if cachedPath != "" { | |
| 356 | - | return cachedPath | |
| 353 | + | // Get exported favicon path from FaviconService | |
| 354 | + | // FaviconService exports all favicons as 32x32 PNG files for CLI access | |
| 355 | + | if option.Value != "" { | |
| 356 | + | faviconPath := services.GetExportedFaviconPath(dataDir, option.Value) | |
| 357 | + | // Check if file exists and is recent (within 7 days) | |
| 358 | + | if info, err := os.Stat(faviconPath); err == nil { | |
| 359 | + | if time.Since(info.ModTime()) < 7*24*time.Hour { | |
| 360 | + | return faviconPath | |
| 361 | + | } | |
| 357 | 362 | } | |
| 358 | - | // Start async download for next time | |
| 359 | - | faviconCache.CacheAsync(option.FaviconURL) | |
| 360 | 363 | } | |
| 361 | 364 | ||
| 362 | 365 | // No favicon available - return empty string to show no icon | |
| @@ -8,8 +8,11 @@ import ( | |||||
| 8 | "os" | 8 | "os" | ||
| 9 | "sort" | 9 | "sort" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | + | "time" | |||
| 11 | 12 | ||||
| 12 | "github.com/bnema/dumber/internal/cache" | 13 | "github.com/bnema/dumber/internal/cache" | ||
| 14 | + | "github.com/bnema/dumber/internal/config" | |||
| 15 | + | "github.com/bnema/dumber/internal/services" | |||
| 13 | "github.com/spf13/cobra" | 16 | "github.com/spf13/cobra" | ||
| 14 | ) | 17 | ) | ||
| 15 | 18 | ||||
| @@ -63,11 +66,10 @@ func generateOptions(cli *CLI) error { | |||||
| 63 | cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries | 66 | cacheConfig.MaxResults = 50 // Match the old maxHistoryEntries | ||
| 64 | cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig) | 67 | cacheManager := cache.NewCacheManager(cli.Queries, cacheConfig) | ||
| 65 | 68 | ||||
| 66 | - | // Initialize favicon cache | 69 | + | // Get data directory for favicon exports |
| 67 | - | faviconCache, err := cache.NewFaviconCache() | 70 | + | dataDir, err := config.GetDataDir() |
| 68 | if err != nil { | 71 | if err != nil { | ||
| 69 | - | // Continue without favicon cache if it fails | 72 | + | dataDir = "" // Continue without favicons if data dir fails |
| 70 | - | faviconCache = nil | |||
| 71 | } | 73 | } | ||
| 72 | 74 | ||||
| 73 | // Get top entries from cache (this is blazingly fast!) | 75 | // Get top entries from cache (this is blazingly fast!) | ||
| @@ -139,7 +141,7 @@ func generateOptions(cli *CLI) error { | |||||
| 139 | 141 | ||||
| 140 | // Output options to stdout with icon specifications | 142 | // Output options to stdout with icon specifications | ||
| 141 | for _, option := range options { | 143 | for _, option := range options { | ||
| 142 | - | iconName := getIconName(option, faviconCache) | 144 | + | iconName := getIconName(option, dataDir) |
| 143 | if iconName != "" { | 145 | if iconName != "" { | ||
| 144 | fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName) | 146 | fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName) | ||
| 145 | } else { | 147 | } else { | ||
| @@ -157,11 +159,10 @@ func generateOptions(cli *CLI) error { | |||||
| 157 | func generateOptionsFallback(cli *CLI) error { | 159 | func generateOptionsFallback(cli *CLI) error { | ||
| 158 | ctx := context.Background() | 160 | ctx := context.Background() | ||
| 159 | 161 | ||||
| 160 | - | // Initialize favicon cache | 162 | + | // Get data directory for favicon exports |
| 161 | - | faviconCache, err := cache.NewFaviconCache() | 163 | + | dataDir, err := config.GetDataDir() |
| 162 | if err != nil { | 164 | if err != nil { | ||
| 163 | - | // Continue without favicon cache if it fails | 165 | + | dataDir = "" // Continue without favicons if data dir fails |
| 164 | - | faviconCache = nil | |||
| 165 | } | 166 | } | ||
| 166 | 167 | ||||
| 167 | const maxHistoryEntries = 50 | 168 | const maxHistoryEntries = 50 | ||
| @@ -240,7 +241,7 @@ func generateOptionsFallback(cli *CLI) error { | |||||
| 240 | 241 | ||||
| 241 | // Output options to stdout with icon specifications | 242 | // Output options to stdout with icon specifications | ||
| 242 | for _, option := range options { | 243 | for _, option := range options { | ||
| 243 | - | iconName := getIconName(option, faviconCache) | 244 | + | iconName := getIconName(option, dataDir) |
| 244 | if iconName != "" { | 245 | if iconName != "" { | ||
| 245 | fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName) | 246 | fmt.Printf("%s\x00icon\x1f%s\n", option.Display, iconName) | ||
| 246 | } else { | 247 | } else { | ||
| @@ -344,19 +345,21 @@ func parseSelection(selection string) string { | |||||
| 344 | } | 345 | } | ||
| 345 | 346 | ||||
| 346 | // getIconName determines the appropriate icon name for a dmenu option | 347 | // getIconName determines the appropriate icon name for a dmenu option | ||
| 347 | - | func getIconName(option DmenuOption, faviconCache *cache.FaviconCache) string { | 348 | + | func getIconName(option DmenuOption, dataDir string) string { |
| 348 | if option.Type != historyType { | 349 | if option.Type != historyType { | ||
| 349 | return "" | 350 | return "" | ||
| 350 | } | 351 | } | ||
| 351 | 352 | ||||
| 352 | - | // Only use cached favicons - no system theme fallbacks for consistent sizing | 353 | + | // Get exported favicon path from FaviconService |
| 353 | - | if option.FaviconURL != "" && faviconCache != nil { | 354 | + | // FaviconService exports all favicons as 32x32 PNG files for CLI access |
| 354 | - | cachedPath := faviconCache.GetCachedPath(option.FaviconURL) | 355 | + | if option.Value != "" { |
| 355 | - | if cachedPath != "" { | 356 | + | faviconPath := services.GetExportedFaviconPath(dataDir, option.Value) |
| 356 | - | return cachedPath | 357 | + | // Check if file exists and is recent (within 7 days) |
| 358 | + | if info, err := os.Stat(faviconPath); err == nil { | |||
| 359 | + | if time.Since(info.ModTime()) < 7*24*time.Hour { | |||
| 360 | + | return faviconPath | |||
| 361 | + | } | |||
| 357 | } | 362 | } | ||
| 358 | - | // Start async download for next time | |||
| 359 | - | faviconCache.CacheAsync(option.FaviconURL) | |||
| 360 | } | 363 | } | ||
| 361 | 364 | ||||
| 362 | // No favicon available - return empty string to show no icon | 365 | // No favicon available - return empty string to show no icon | ||
M internal/cli/root.go +0 -8
| @@ -8,7 +8,6 @@ import ( | |||
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | 10 | ||
| 11 | - | "github.com/bnema/dumber/internal/cache" | |
| 12 | 11 | "github.com/bnema/dumber/internal/config" | |
| 13 | 12 | "github.com/bnema/dumber/internal/db" | |
| 14 | 13 | "github.com/bnema/dumber/internal/services" | |
| @@ -77,13 +76,6 @@ func NewCLI() (*CLI, error) { | |||
| 77 | 76 | queries := db.New(database) | |
| 78 | 77 | parserService := services.NewParserService(cfg, queries) | |
| 79 | 78 | ||
| 80 | - | // Clean old cached favicons on startup (async, non-blocking) | |
| 81 | - | go func() { | |
| 82 | - | if faviconCache, err := cache.NewFaviconCache(); err == nil { | |
| 83 | - | faviconCache.CleanOld() | |
| 84 | - | } | |
| 85 | - | }() | |
| 86 | - | ||
| 87 | 79 | return &CLI{ | |
| 88 | 80 | DB: database, | |
| 89 | 81 | Queries: queries, | |
| @@ -8,7 +8,6 @@ import ( | |||||
| 8 | "os" | 8 | "os" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | 10 | ||||
| 11 | - | "github.com/bnema/dumber/internal/cache" | |||
| 12 | "github.com/bnema/dumber/internal/config" | 11 | "github.com/bnema/dumber/internal/config" | ||
| 13 | "github.com/bnema/dumber/internal/db" | 12 | "github.com/bnema/dumber/internal/db" | ||
| 14 | "github.com/bnema/dumber/internal/services" | 13 | "github.com/bnema/dumber/internal/services" | ||
| @@ -77,13 +76,6 @@ func NewCLI() (*CLI, error) { | |||||
| 77 | queries := db.New(database) | 76 | queries := db.New(database) | ||
| 78 | parserService := services.NewParserService(cfg, queries) | 77 | parserService := services.NewParserService(cfg, queries) | ||
| 79 | 78 | ||||
| 80 | - | // Clean old cached favicons on startup (async, non-blocking) | |||
| 81 | - | go func() { | |||
| 82 | - | if faviconCache, err := cache.NewFaviconCache(); err == nil { | |||
| 83 | - | faviconCache.CleanOld() | |||
| 84 | - | } | |||
| 85 | - | }() | |||
| 86 | - | ||||
| 87 | return &CLI{ | 79 | return &CLI{ | ||
| 88 | DB: database, | 80 | DB: database, | ||
| 89 | Queries: queries, | 81 | Queries: queries, | ||
M internal/config/defaults.go +5 -5
| @@ -32,11 +32,11 @@ const ( | |||
| 32 | 32 | defaultPopupPlacement = "right" | |
| 33 | 33 | ||
| 34 | 34 | // Workspace styling defaults | |
| 35 | - | defaultBorderWidth = 2 | |
| 36 | - | defaultBorderColor = "@theme_selected_bg_color" | |
| 37 | - | defaultPaneModeBorderColor = "#FFA500" // Orange for pane mode indicator | |
| 38 | - | defaultTransitionDuration = 120 | |
| 39 | - | defaultBorderRadius = 0 | |
| 35 | + | defaultBorderWidth = 2 | |
| 36 | + | defaultBorderColor = "@theme_selected_bg_color" | |
| 37 | + | defaultPaneModeBorderColor = "#FFA500" // Orange for pane mode indicator | |
| 38 | + | defaultTransitionDuration = 120 | |
| 39 | + | defaultBorderRadius = 0 | |
| 40 | 40 | ) | |
| 41 | 41 | ||
| 42 | 42 | // getDefaultLogDir returns the default log directory, falls back to empty string on error | |
| @@ -32,11 +32,11 @@ const ( | |||||
| 32 | defaultPopupPlacement = "right" | 32 | defaultPopupPlacement = "right" | ||
| 33 | 33 | ||||
| 34 | // Workspace styling defaults | 34 | // Workspace styling defaults | ||
| 35 | - | defaultBorderWidth = 2 | 35 | + | defaultBorderWidth = 2 |
| 36 | - | defaultBorderColor = "@theme_selected_bg_color" | 36 | + | defaultBorderColor = "@theme_selected_bg_color" |
| 37 | - | defaultPaneModeBorderColor = "#FFA500" // Orange for pane mode indicator | 37 | + | defaultPaneModeBorderColor = "#FFA500" // Orange for pane mode indicator |
| 38 | - | defaultTransitionDuration = 120 | 38 | + | defaultTransitionDuration = 120 |
| 39 | - | defaultBorderRadius = 0 | 39 | + | defaultBorderRadius = 0 |
| 40 | ) | 40 | ) | ||
| 41 | 41 | ||||
| 42 | // getDefaultLogDir returns the default log directory, falls back to empty string on error | 42 | // getDefaultLogDir returns the default log directory, falls back to empty string on error | ||
M internal/db/interfaces.go +1 -0
| @@ -17,6 +17,7 @@ type ZoomQuerier interface { | |||
| 17 | 17 | // HistoryQuerier defines the interface for history-related database operations | |
| 18 | 18 | type HistoryQuerier interface { | |
| 19 | 19 | GetHistory(ctx context.Context, limit int64) ([]History, error) | |
| 20 | + | GetHistoryEntry(ctx context.Context, url string) (History, error) | |
| 20 | 21 | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | |
| 21 | 22 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | |
| 22 | 23 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | |
| @@ -17,6 +17,7 @@ type ZoomQuerier interface { | |||||
| 17 | // HistoryQuerier defines the interface for history-related database operations | 17 | // HistoryQuerier defines the interface for history-related database operations | ||
| 18 | type HistoryQuerier interface { | 18 | type HistoryQuerier interface { | ||
| 19 | GetHistory(ctx context.Context, limit int64) ([]History, error) | 19 | GetHistory(ctx context.Context, limit int64) ([]History, error) | ||
| 20 | + | GetHistoryEntry(ctx context.Context, url string) (History, error) | |||
| 20 | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | 21 | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | ||
| 21 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | 22 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | ||
| 22 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | 23 | AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error | ||
M internal/migrations/migrations_test.go +4 -4
| @@ -418,10 +418,10 @@ func TestExpectedTablesExistAfterMigrations(t *testing.T) { | |||
| 418 | 418 | ||
| 419 | 419 | // Expected tables based on current migrations | |
| 420 | 420 | expectedTables := []string{ | |
| 421 | - | "schema_migrations", // Migration tracking | |
| 422 | - | "history", // 001_initial.sql | |
| 423 | - | "shortcuts", // 001_initial.sql | |
| 424 | - | "zoom_levels", // 003_add_zoom_levels.sql | |
| 421 | + | "schema_migrations", // Migration tracking | |
| 422 | + | "history", // 001_initial.sql | |
| 423 | + | "shortcuts", // 001_initial.sql | |
| 424 | + | "zoom_levels", // 003_add_zoom_levels.sql | |
| 425 | 425 | "certificate_validations", // 005_add_certificate_validations.sql | |
| 426 | 426 | } | |
| 427 | 427 | ||
| @@ -418,10 +418,10 @@ func TestExpectedTablesExistAfterMigrations(t *testing.T) { | |||||
| 418 | 418 | ||||
| 419 | // Expected tables based on current migrations | 419 | // Expected tables based on current migrations | ||
| 420 | expectedTables := []string{ | 420 | expectedTables := []string{ | ||
| 421 | - | "schema_migrations", // Migration tracking | 421 | + | "schema_migrations", // Migration tracking |
| 422 | - | "history", // 001_initial.sql | 422 | + | "history", // 001_initial.sql |
| 423 | - | "shortcuts", // 001_initial.sql | 423 | + | "shortcuts", // 001_initial.sql |
| 424 | - | "zoom_levels", // 003_add_zoom_levels.sql | 424 | + | "zoom_levels", // 003_add_zoom_levels.sql |
| 425 | "certificate_validations", // 005_add_certificate_validations.sql | 425 | "certificate_validations", // 005_add_certificate_validations.sql | ||
| 426 | } | 426 | } | ||
| 427 | 427 | ||||
M internal/services/browser.go +4 -106
| @@ -3,7 +3,6 @@ package services | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | - | "crypto/md5" | |
| 7 | 6 | "database/sql" | |
| 8 | 7 | "embed" | |
| 9 | 8 | "errors" | |
| @@ -13,7 +12,6 @@ import ( | |||
| 13 | 12 | neturl "net/url" | |
| 14 | 13 | "time" | |
| 15 | 14 | ||
| 16 | - | "github.com/bnema/dumber/internal/cache" | |
| 17 | 15 | "github.com/bnema/dumber/internal/config" | |
| 18 | 16 | "github.com/bnema/dumber/internal/db" | |
| 19 | 17 | "github.com/bnema/dumber/pkg/webkit" | |
| @@ -91,20 +89,7 @@ func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) { | |||
| 91 | 89 | // AttachWebView connects a native WebKit WebView to this service for integration. | |
| 92 | 90 | func (s *BrowserService) AttachWebView(view *webkit.WebView) { | |
| 93 | 91 | s.webView = view | |
| 94 | - | ||
| 95 | - | // Register legacy favicon change handler (binary data - fallback) | |
| 96 | - | // This is kept for backward compatibility but is rarely used | |
| 97 | - | // Modern favicon handling uses the URI-based handler registered in buildPane() | |
| 98 | - | view.RegisterFaviconChangedHandler(func(data []byte) { | |
| 99 | - | if currentURL := view.GetCurrentURL(); currentURL != "" { | |
| 100 | - | log.Printf("[favicon] Favicon detected for %s, data size: %d bytes", currentURL, len(data)) | |
| 101 | - | s.handleFaviconChanged(currentURL, data) | |
| 102 | - | } | |
| 103 | - | }) | |
| 104 | - | ||
| 105 | - | // Note: RegisterFaviconURIChangedHandler is now only called in buildPane() | |
| 106 | - | // to avoid duplicate handler registration which causes multiple downloads | |
| 107 | - | // of the same favicon and file handle race conditions | |
| 92 | + | // Favicon handling is now managed by FaviconService | |
| 108 | 93 | } | |
| 109 | 94 | ||
| 110 | 95 | // LoadGUIBundle loads the unified GUI bundle from assets | |
| @@ -302,11 +287,13 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit | |||
| 302 | 287 | result := make([]HistoryEntry, len(entries)) | |
| 303 | 288 | for i, entry := range entries { | |
| 304 | 289 | vc := clampToInt32(entry.VisitCount.Int64) | |
| 290 | + | // For omnibox display, convert favicon URI to file path when available | |
| 291 | + | faviconURL := entry.FaviconUrl.String | |
| 305 | 292 | result[i] = HistoryEntry{ | |
| 306 | 293 | ID: entry.ID, | |
| 307 | 294 | URL: entry.Url, | |
| 308 | 295 | Title: entry.Title.String, | |
| 309 | - | FaviconURL: entry.FaviconUrl.String, | |
| 296 | + | FaviconURL: faviconURL, | |
| 310 | 297 | VisitCount: vc, | |
| 311 | 298 | LastVisited: entry.LastVisited.Time, | |
| 312 | 299 | CreatedAt: entry.CreatedAt.Time, | |
| @@ -568,95 +555,6 @@ func zoomKeyFromURL(raw string) string { | |||
| 568 | 555 | // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages. | |
| 569 | 556 | func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) } | |
| 570 | 557 | ||
| 571 | - | // handleFaviconChanged processes favicon data received from WebKit (legacy binary data method) | |
| 572 | - | func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) { | |
| 573 | - | log.Printf("[favicon] Processing favicon for %s, data size: %d bytes", pageURL, len(pngData)) | |
| 574 | - | ||
| 575 | - | if len(pngData) == 0 { | |
| 576 | - | log.Printf("[favicon] No favicon data for %s", pageURL) | |
| 577 | - | return | |
| 578 | - | } | |
| 579 | - | ||
| 580 | - | ctx := context.Background() | |
| 581 | - | ||
| 582 | - | // Parse URL for validation | |
| 583 | - | parsedURL, err := neturl.Parse(pageURL) | |
| 584 | - | if err != nil { | |
| 585 | - | return | |
| 586 | - | } | |
| 587 | - | ||
| 588 | - | // Skip favicon update for localhost, file://, or special schemes | |
| 589 | - | if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { | |
| 590 | - | return | |
| 591 | - | } | |
| 592 | - | if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" { | |
| 593 | - | return | |
| 594 | - | } | |
| 595 | - | ||
| 596 | - | // Cache the PNG data directly | |
| 597 | - | if faviconCache, err := cache.NewFaviconCache(); err == nil { | |
| 598 | - | if err := faviconCache.CachePNGData(pageURL, pngData); err != nil { | |
| 599 | - | // Log error but don't fail | |
| 600 | - | log.Printf("[favicon] Failed to cache favicon for %s: %v", pageURL, err) | |
| 601 | - | } else { | |
| 602 | - | log.Printf("[favicon] Successfully cached favicon for %s (%d bytes)", pageURL, len(pngData)) | |
| 603 | - | } | |
| 604 | - | } else { | |
| 605 | - | log.Printf("[favicon] Failed to create favicon cache: %v", err) | |
| 606 | - | } | |
| 607 | - | ||
| 608 | - | // Store a reference to the cached file in the database | |
| 609 | - | // We use a consistent path format for the favicon URL | |
| 610 | - | faviconCacheURL := fmt.Sprintf("dumb://favicon/%x.png", md5.Sum([]byte(pageURL))) | |
| 611 | - | faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true} | |
| 612 | - | if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil { | |
| 613 | - | // Silently fail - favicon is not critical | |
| 614 | - | log.Printf("[browser] Failed to update favicon URL in database for %s: %v", pageURL, err) | |
| 615 | - | } | |
| 616 | - | } | |
| 617 | - | ||
| 618 | - | // ProcessFaviconURI is a public wrapper for handling favicon URI changes from any webview | |
| 619 | - | func (s *BrowserService) ProcessFaviconURI(pageURL string, faviconURI string) { | |
| 620 | - | s.handleFaviconURIChanged(pageURL, faviconURI) | |
| 621 | - | } | |
| 622 | - | ||
| 623 | - | // handleFaviconURIChanged processes favicon URI changes from WebKit's native favicon database | |
| 624 | - | func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI string) { | |
| 625 | - | log.Printf("[favicon] Processing favicon URI for %s: %s", pageURL, faviconURI) | |
| 626 | - | ||
| 627 | - | ctx := context.Background() | |
| 628 | - | ||
| 629 | - | // Parse URL for validation | |
| 630 | - | parsedURL, err := neturl.Parse(pageURL) | |
| 631 | - | if err != nil { | |
| 632 | - | log.Printf("[favicon] Invalid page URL: %s", pageURL) | |
| 633 | - | return | |
| 634 | - | } | |
| 635 | - | ||
| 636 | - | // Skip favicon update for localhost, file://, or special schemes | |
| 637 | - | if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { | |
| 638 | - | return | |
| 639 | - | } | |
| 640 | - | if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" { | |
| 641 | - | return | |
| 642 | - | } | |
| 643 | - | ||
| 644 | - | // Store the favicon URI directly in the database | |
| 645 | - | // The frontend will handle SVG vs PNG/ICO detection | |
| 646 | - | faviconNullString := sql.NullString{String: faviconURI, Valid: true} | |
| 647 | - | if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil { | |
| 648 | - | log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err) | |
| 649 | - | } | |
| 650 | - | ||
| 651 | - | // Download and cache the favicon asynchronously (same approach as dmenu) | |
| 652 | - | if faviconCache, err := cache.NewFaviconCache(); err == nil { | |
| 653 | - | faviconCache.CacheAsync(faviconURI) | |
| 654 | - | log.Printf("[favicon] Started async download of favicon from %s for page %s", faviconURI, pageURL) | |
| 655 | - | } else { | |
| 656 | - | log.Printf("[favicon] Failed to create favicon cache: %v", err) | |
| 657 | - | } | |
| 658 | - | } | |
| 659 | - | ||
| 660 | 558 | // ColorPalettesResponse holds light and dark palettes for JSON marshaling | |
| 661 | 559 | type ColorPalettesResponse struct { | |
| 662 | 560 | Light config.ColorPalette `json:"light"` | |
| @@ -3,7 +3,6 @@ package services | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | - | "crypto/md5" | |||
| 7 | "database/sql" | 6 | "database/sql" | ||
| 8 | "embed" | 7 | "embed" | ||
| 9 | "errors" | 8 | "errors" | ||
| @@ -13,7 +12,6 @@ import ( | |||||
| 13 | neturl "net/url" | 12 | neturl "net/url" | ||
| 14 | "time" | 13 | "time" | ||
| 15 | 14 | ||||
| 16 | - | "github.com/bnema/dumber/internal/cache" | |||
| 17 | "github.com/bnema/dumber/internal/config" | 15 | "github.com/bnema/dumber/internal/config" | ||
| 18 | "github.com/bnema/dumber/internal/db" | 16 | "github.com/bnema/dumber/internal/db" | ||
| 19 | "github.com/bnema/dumber/pkg/webkit" | 17 | "github.com/bnema/dumber/pkg/webkit" | ||
| @@ -91,20 +89,7 @@ func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) { | |||||
| 91 | // AttachWebView connects a native WebKit WebView to this service for integration. | 89 | // AttachWebView connects a native WebKit WebView to this service for integration. | ||
| 92 | func (s *BrowserService) AttachWebView(view *webkit.WebView) { | 90 | func (s *BrowserService) AttachWebView(view *webkit.WebView) { | ||
| 93 | s.webView = view | 91 | s.webView = view | ||
| 94 | - | 92 | + | // Favicon handling is now managed by FaviconService | |
| 95 | - | // Register legacy favicon change handler (binary data - fallback) | |||
| 96 | - | // This is kept for backward compatibility but is rarely used | |||
| 97 | - | // Modern favicon handling uses the URI-based handler registered in buildPane() | |||
| 98 | - | view.RegisterFaviconChangedHandler(func(data []byte) { | |||
| 99 | - | if currentURL := view.GetCurrentURL(); currentURL != "" { | |||
| 100 | - | log.Printf("[favicon] Favicon detected for %s, data size: %d bytes", currentURL, len(data)) | |||
| 101 | - | s.handleFaviconChanged(currentURL, data) | |||
| 102 | - | } | |||
| 103 | - | }) | |||
| 104 | - | ||||
| 105 | - | // Note: RegisterFaviconURIChangedHandler is now only called in buildPane() | |||
| 106 | - | // to avoid duplicate handler registration which causes multiple downloads | |||
| 107 | - | // of the same favicon and file handle race conditions | |||
| 108 | } | 93 | } | ||
| 109 | 94 | ||||
| 110 | // LoadGUIBundle loads the unified GUI bundle from assets | 95 | // LoadGUIBundle loads the unified GUI bundle from assets | ||
| @@ -302,11 +287,13 @@ func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit | |||||
| 302 | result := make([]HistoryEntry, len(entries)) | 287 | result := make([]HistoryEntry, len(entries)) | ||
| 303 | for i, entry := range entries { | 288 | for i, entry := range entries { | ||
| 304 | vc := clampToInt32(entry.VisitCount.Int64) | 289 | vc := clampToInt32(entry.VisitCount.Int64) | ||
| 290 | + | // For omnibox display, convert favicon URI to file path when available | |||
| 291 | + | faviconURL := entry.FaviconUrl.String | |||
| 305 | result[i] = HistoryEntry{ | 292 | result[i] = HistoryEntry{ | ||
| 306 | ID: entry.ID, | 293 | ID: entry.ID, | ||
| 307 | URL: entry.Url, | 294 | URL: entry.Url, | ||
| 308 | Title: entry.Title.String, | 295 | Title: entry.Title.String, | ||
| 309 | - | FaviconURL: entry.FaviconUrl.String, | 296 | + | FaviconURL: faviconURL, |
| 310 | VisitCount: vc, | 297 | VisitCount: vc, | ||
| 311 | LastVisited: entry.LastVisited.Time, | 298 | LastVisited: entry.LastVisited.Time, | ||
| 312 | CreatedAt: entry.CreatedAt.Time, | 299 | CreatedAt: entry.CreatedAt.Time, | ||
| @@ -568,95 +555,6 @@ func zoomKeyFromURL(raw string) string { | |||||
| 568 | // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages. | 555 | // ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages. | ||
| 569 | func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) } | 556 | func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) } | ||
| 570 | 557 | ||||
| 571 | - | // handleFaviconChanged processes favicon data received from WebKit (legacy binary data method) | |||
| 572 | - | func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) { | |||
| 573 | - | log.Printf("[favicon] Processing favicon for %s, data size: %d bytes", pageURL, len(pngData)) | |||
| 574 | - | ||||
| 575 | - | if len(pngData) == 0 { | |||
| 576 | - | log.Printf("[favicon] No favicon data for %s", pageURL) | |||
| 577 | - | return | |||
| 578 | - | } | |||
| 579 | - | ||||
| 580 | - | ctx := context.Background() | |||
| 581 | - | ||||
| 582 | - | // Parse URL for validation | |||
| 583 | - | parsedURL, err := neturl.Parse(pageURL) | |||
| 584 | - | if err != nil { | |||
| 585 | - | return | |||
| 586 | - | } | |||
| 587 | - | ||||
| 588 | - | // Skip favicon update for localhost, file://, or special schemes | |||
| 589 | - | if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { | |||
| 590 | - | return | |||
| 591 | - | } | |||
| 592 | - | if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" { | |||
| 593 | - | return | |||
| 594 | - | } | |||
| 595 | - | ||||
| 596 | - | // Cache the PNG data directly | |||
| 597 | - | if faviconCache, err := cache.NewFaviconCache(); err == nil { | |||
| 598 | - | if err := faviconCache.CachePNGData(pageURL, pngData); err != nil { | |||
| 599 | - | // Log error but don't fail | |||
| 600 | - | log.Printf("[favicon] Failed to cache favicon for %s: %v", pageURL, err) | |||
| 601 | - | } else { | |||
| 602 | - | log.Printf("[favicon] Successfully cached favicon for %s (%d bytes)", pageURL, len(pngData)) | |||
| 603 | - | } | |||
| 604 | - | } else { | |||
| 605 | - | log.Printf("[favicon] Failed to create favicon cache: %v", err) | |||
| 606 | - | } | |||
| 607 | - | ||||
| 608 | - | // Store a reference to the cached file in the database | |||
| 609 | - | // We use a consistent path format for the favicon URL | |||
| 610 | - | faviconCacheURL := fmt.Sprintf("dumb://favicon/%x.png", md5.Sum([]byte(pageURL))) | |||
| 611 | - | faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true} | |||
| 612 | - | if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil { | |||
| 613 | - | // Silently fail - favicon is not critical | |||
| 614 | - | log.Printf("[browser] Failed to update favicon URL in database for %s: %v", pageURL, err) | |||
| 615 | - | } | |||
| 616 | - | } | |||
| 617 | - | ||||
| 618 | - | // ProcessFaviconURI is a public wrapper for handling favicon URI changes from any webview | |||
| 619 | - | func (s *BrowserService) ProcessFaviconURI(pageURL string, faviconURI string) { | |||
| 620 | - | s.handleFaviconURIChanged(pageURL, faviconURI) | |||
| 621 | - | } | |||
| 622 | - | ||||
| 623 | - | // handleFaviconURIChanged processes favicon URI changes from WebKit's native favicon database | |||
| 624 | - | func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI string) { | |||
| 625 | - | log.Printf("[favicon] Processing favicon URI for %s: %s", pageURL, faviconURI) | |||
| 626 | - | ||||
| 627 | - | ctx := context.Background() | |||
| 628 | - | ||||
| 629 | - | // Parse URL for validation | |||
| 630 | - | parsedURL, err := neturl.Parse(pageURL) | |||
| 631 | - | if err != nil { | |||
| 632 | - | log.Printf("[favicon] Invalid page URL: %s", pageURL) | |||
| 633 | - | return | |||
| 634 | - | } | |||
| 635 | - | ||||
| 636 | - | // Skip favicon update for localhost, file://, or special schemes | |||
| 637 | - | if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { | |||
| 638 | - | return | |||
| 639 | - | } | |||
| 640 | - | if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" { | |||
| 641 | - | return | |||
| 642 | - | } | |||
| 643 | - | ||||
| 644 | - | // Store the favicon URI directly in the database | |||
| 645 | - | // The frontend will handle SVG vs PNG/ICO detection | |||
| 646 | - | faviconNullString := sql.NullString{String: faviconURI, Valid: true} | |||
| 647 | - | if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil { | |||
| 648 | - | log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err) | |||
| 649 | - | } | |||
| 650 | - | ||||
| 651 | - | // Download and cache the favicon asynchronously (same approach as dmenu) | |||
| 652 | - | if faviconCache, err := cache.NewFaviconCache(); err == nil { | |||
| 653 | - | faviconCache.CacheAsync(faviconURI) | |||
| 654 | - | log.Printf("[favicon] Started async download of favicon from %s for page %s", faviconURI, pageURL) | |||
| 655 | - | } else { | |||
| 656 | - | log.Printf("[favicon] Failed to create favicon cache: %v", err) | |||
| 657 | - | } | |||
| 658 | - | } | |||
| 659 | - | ||||
| 660 | // ColorPalettesResponse holds light and dark palettes for JSON marshaling | 558 | // ColorPalettesResponse holds light and dark palettes for JSON marshaling | ||
| 661 | type ColorPalettesResponse struct { | 559 | type ColorPalettesResponse struct { | ||
| 662 | Light config.ColorPalette `json:"light"` | 560 | Light config.ColorPalette `json:"light"` | ||
A internal/services/favicon_service.go +454 -0
| @@ -0,0 +1,454 @@ | |||
| 1 | + | // Package services contains application services that orchestrate business logic. | |
| 2 | + | package services | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "database/sql" | |
| 7 | + | "fmt" | |
| 8 | + | "log" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/dumber/internal/db" | |
| 14 | + | "github.com/bnema/dumber/pkg/webkit" | |
| 15 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |
| 16 | + | "github.com/diamondburned/gotk4/pkg/gio/v2" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // FaviconService provides unified favicon access using WebKitGTK's native database. | |
| 20 | + | // The service keeps WebKit's FaviconDatabase as the source of truth and augments it | |
| 21 | + | // with fallback rasterisation for SVG favicons so that callers always receive a | |
| 22 | + | // 32x32 texture or an explicit error. | |
| 23 | + | type FaviconService struct { | |
| 24 | + | faviconDB *webkit.FaviconDatabase | |
| 25 | + | dbQueries db.DatabaseQuerier | |
| 26 | + | targetSize int // Target size for exported favicons (32x32 for high quality) | |
| 27 | + | exportDir string // Directory for exporting favicons for CLI access | |
| 28 | + | dataDir string // WebKit data directory | |
| 29 | + | enableExport bool // Whether to export favicons for CLI tools | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | const ( | |
| 33 | + | // DefaultFaviconSize is the target size for exported favicons (32x32 for high quality) | |
| 34 | + | DefaultFaviconSize = 32 | |
| 35 | + | // FaviconTimeout is the maximum time to wait for a favicon to be available | |
| 36 | + | FaviconTimeout = 5 * time.Second | |
| 37 | + | ) | |
| 38 | + | ||
| 39 | + | // ServiceName returns the service name for identification | |
| 40 | + | func (fs *FaviconService) ServiceName() string { | |
| 41 | + | return "FaviconService" | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // NewFaviconService creates a new favicon service | |
| 45 | + | func NewFaviconService(faviconDB *webkit.FaviconDatabase, queries db.DatabaseQuerier, dataDir string) (*FaviconService, error) { | |
| 46 | + | if faviconDB == nil { | |
| 47 | + | return nil, fmt.Errorf("favicon database cannot be nil") | |
| 48 | + | } | |
| 49 | + | if queries == nil { | |
| 50 | + | return nil, fmt.Errorf("database queries cannot be nil") | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // Create export directory for CLI access | |
| 54 | + | exportDir := filepath.Join(dataDir, "favicons-export") | |
| 55 | + | if err := os.MkdirAll(exportDir, 0755); err != nil { | |
| 56 | + | return nil, fmt.Errorf("failed to create favicon export directory: %w", err) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | fs := &FaviconService{ | |
| 60 | + | faviconDB: faviconDB, | |
| 61 | + | dbQueries: queries, | |
| 62 | + | targetSize: DefaultFaviconSize, | |
| 63 | + | exportDir: exportDir, | |
| 64 | + | dataDir: dataDir, | |
| 65 | + | enableExport: true, | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Register the favicon changed handler ONCE at the service level | |
| 69 | + | // This is critical to avoid duplicate handlers when multiple WebViews exist | |
| 70 | + | // The FaviconDatabase is shared across all WebViews (at NetworkSession level) | |
| 71 | + | faviconDB.ConnectFaviconChanged(func(pageURI, faviconURI string) { | |
| 72 | + | if err := fs.OnFaviconChanged(pageURI, faviconURI); err != nil { | |
| 73 | + | log.Printf("[favicon] Handler error for %s: %v", pageURI, err) | |
| 74 | + | } | |
| 75 | + | }) | |
| 76 | + | ||
| 77 | + | log.Printf("[favicon] Registered single shared favicon handler at FaviconService level") | |
| 78 | + | ||
| 79 | + | return fs, nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // OnFaviconChanged handles favicon URI changes from WebKit's favicon database. | |
| 83 | + | // We store the URI (PNG, SVG, etc.) so that callers can later load an appropriate texture. | |
| 84 | + | func (fs *FaviconService) OnFaviconChanged(pageURL, faviconURI string) error { | |
| 85 | + | log.Printf("[favicon] Favicon changed for %s: %s", pageURL, faviconURI) | |
| 86 | + | ||
| 87 | + | ctx := context.Background() | |
| 88 | + | ||
| 89 | + | if !fs.shouldProcessURL(pageURL) { | |
| 90 | + | return nil | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | nullString := sql.NullString{String: faviconURI, Valid: faviconURI != ""} | |
| 94 | + | if err := fs.dbQueries.UpdateHistoryFavicon(ctx, nullString, pageURL); err != nil { | |
| 95 | + | log.Printf("[favicon] Failed to update favicon URI in database for %s: %v", pageURL, err) | |
| 96 | + | return fmt.Errorf("failed to update favicon in database: %w", err) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // Also update all other URLs from the same domain to propagate favicon | |
| 100 | + | // This handles multiple paths on the same domain (e.g., google.com/search?q=foo and google.com/search?q=bar) | |
| 101 | + | fs.propagateFaviconToDomain(pageURL, faviconURI) | |
| 102 | + | ||
| 103 | + | // Proactively render and export favicon for omnibox/CLI use | |
| 104 | + | // This ensures favicons are available ASAP for suggestions | |
| 105 | + | if fs.enableExport { | |
| 106 | + | go fs.preloadFavicon(pageURL, faviconURI) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | return nil | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // propagateFaviconToDomain updates all history entries from the same domain | |
| 113 | + | // with the favicon. This ensures multiple paths on the same domain share the favicon | |
| 114 | + | // even if WebKit only signals it once per domain (KISS + DRY principle). | |
| 115 | + | func (fs *FaviconService) propagateFaviconToDomain(pageURL, faviconURI string) { | |
| 116 | + | ctx := context.Background() | |
| 117 | + | ||
| 118 | + | parsedURL, err := webkit.ParseURL(pageURL) | |
| 119 | + | if err != nil { | |
| 120 | + | return // Invalid URL, skip propagation | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | faviconNull := sql.NullString{String: faviconURI, Valid: true} | |
| 124 | + | ||
| 125 | + | // Search for exact domain match first | |
| 126 | + | domainPattern := sql.NullString{String: "%://" + parsedURL.Host + "%", Valid: true} | |
| 127 | + | entries, err := fs.dbQueries.SearchHistory(ctx, domainPattern, sql.NullString{}, 1000) | |
| 128 | + | if err != nil { | |
| 129 | + | log.Printf("[favicon] Failed to search history for domain %s: %v", parsedURL.Host, err) | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // Also search for www variant to handle www/non-www redirects | |
| 134 | + | // google.com should share favicon with www.google.com and vice versa | |
| 135 | + | var altPattern sql.NullString | |
| 136 | + | if len(parsedURL.Host) > 4 && parsedURL.Host[:4] == "www." { | |
| 137 | + | // Current is www.google.com, also search google.com | |
| 138 | + | hostWithoutWWW := parsedURL.Host[4:] | |
| 139 | + | altPattern = sql.NullString{String: "%://" + hostWithoutWWW + "%", Valid: true} | |
| 140 | + | } else { | |
| 141 | + | // Current is google.com, also search www.google.com | |
| 142 | + | altPattern = sql.NullString{String: "%://www." + parsedURL.Host + "%", Valid: true} | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | altEntries, _ := fs.dbQueries.SearchHistory(ctx, altPattern, sql.NullString{}, 1000) | |
| 146 | + | entries = append(entries, altEntries...) | |
| 147 | + | ||
| 148 | + | // Update all entries that don't already have a favicon | |
| 149 | + | for _, entry := range entries { | |
| 150 | + | if entry.FaviconUrl.String == "" { | |
| 151 | + | if err := fs.dbQueries.UpdateHistoryFavicon(ctx, faviconNull, entry.Url); err != nil { | |
| 152 | + | log.Printf("[favicon] Failed to propagate favicon to %s: %v", entry.Url, err) | |
| 153 | + | } else { | |
| 154 | + | log.Printf("[favicon] Propagated favicon to %s", entry.Url) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // preloadFavicon renders and exports a favicon in the background. | |
| 161 | + | // This is called immediately when OnFaviconChanged fires to ensure | |
| 162 | + | // favicons are ready for omnibox suggestions as soon as possible. | |
| 163 | + | func (fs *FaviconService) preloadFavicon(pageURL, faviconURI string) { | |
| 164 | + | ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout) | |
| 165 | + | defer cancel() | |
| 166 | + | ||
| 167 | + | texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI) | |
| 168 | + | if err != nil { | |
| 169 | + | log.Printf("[favicon] Preload failed for %s: %v", pageURL, err) | |
| 170 | + | return | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | fs.maybeExportTexture(pageURL, texture) | |
| 174 | + | log.Printf("[favicon] Preloaded and exported favicon for %s", pageURL) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // GetFaviconTexture loads a favicon texture asynchronously and calls the callback with the result. | |
| 178 | + | // The callback receives a 32x32 GdkTexture suitable for display in GTK widgets, or an error. | |
| 179 | + | func (fs *FaviconService) GetFaviconTexture(pageURL string, callback func(*gdk.Texture, error)) { | |
| 180 | + | if callback == nil { | |
| 181 | + | return | |
| 182 | + | } | |
| 183 | + | if pageURL == "" { | |
| 184 | + | fs.dispatchTexture(callback, nil, fmt.Errorf("page URL is empty")) | |
| 185 | + | return | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | go func() { | |
| 189 | + | ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout) | |
| 190 | + | defer cancel() | |
| 191 | + | ||
| 192 | + | faviconURI, err := fs.lookupFaviconURI(ctx, pageURL) | |
| 193 | + | if err != nil { | |
| 194 | + | fs.dispatchTexture(callback, nil, err) | |
| 195 | + | return | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI) | |
| 199 | + | if err != nil { | |
| 200 | + | fs.dispatchTexture(callback, nil, err) | |
| 201 | + | return | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | fs.maybeExportTexture(pageURL, texture) | |
| 205 | + | ||
| 206 | + | fs.dispatchTexture(callback, texture, nil) | |
| 207 | + | }() | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (fs *FaviconService) renderFaviconTexture(ctx context.Context, pageURL, faviconURI string) (*gdk.Texture, error) { | |
| 211 | + | if faviconURI == "" { | |
| 212 | + | return nil, fmt.Errorf("no favicon URI available") | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | if fs.isSVGFavicon(faviconURI) { | |
| 216 | + | return fs.loadSVGFaviconTexture(ctx, faviconURI) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | if fs.faviconDB == nil { | |
| 220 | + | return nil, fmt.Errorf("favicon database not initialized") | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | type result struct { | |
| 224 | + | texture *gdk.Texture | |
| 225 | + | err error | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | resultCh := make(chan result, 1) | |
| 229 | + | ||
| 230 | + | fs.faviconDB.Favicon(ctx, pageURL, func(res gio.AsyncResulter) { | |
| 231 | + | if res == nil { | |
| 232 | + | select { | |
| 233 | + | case resultCh <- result{err: fmt.Errorf("favicon async result is nil")}: | |
| 234 | + | default: | |
| 235 | + | } | |
| 236 | + | return | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | texture, err := webkit.FaviconFinishSafe(fs.faviconDB, res) | |
| 240 | + | if err == nil && texture == nil { | |
| 241 | + | err = fmt.Errorf("favicon unavailable") | |
| 242 | + | } | |
| 243 | + | if err == nil { | |
| 244 | + | texture = fs.ensureTextureSize(texture) | |
| 245 | + | if texture == nil { | |
| 246 | + | err = fmt.Errorf("failed to scale favicon texture") | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | select { | |
| 251 | + | case resultCh <- result{texture: texture, err: err}: | |
| 252 | + | default: | |
| 253 | + | } | |
| 254 | + | }) | |
| 255 | + | ||
| 256 | + | select { | |
| 257 | + | case <-ctx.Done(): | |
| 258 | + | return nil, ctx.Err() | |
| 259 | + | case res := <-resultCh: | |
| 260 | + | return res.texture, res.err | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func (fs *FaviconService) loadSVGFaviconTexture(ctx context.Context, faviconURI string) (*gdk.Texture, error) { | |
| 265 | + | file := gio.NewFileForURI(faviconURI) | |
| 266 | + | if file == nil { | |
| 267 | + | return nil, fmt.Errorf("invalid SVG favicon URI: %s", faviconURI) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | data, _, err := file.LoadContents(ctx) | |
| 271 | + | if err != nil { | |
| 272 | + | return nil, fmt.Errorf("load SVG favicon: %w", err) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | texture, err := webkit.TextureFromImageBytes(data) | |
| 276 | + | if err != nil { | |
| 277 | + | return nil, fmt.Errorf("decode SVG favicon: %w", err) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | texture = fs.ensureTextureSize(texture) | |
| 281 | + | if texture == nil { | |
| 282 | + | return nil, fmt.Errorf("failed to scale SVG favicon") | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | return texture, nil | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func (fs *FaviconService) lookupFaviconURI(ctx context.Context, pageURL string) (string, error) { | |
| 289 | + | entry, err := fs.dbQueries.GetHistoryEntry(ctx, pageURL) | |
| 290 | + | if err == nil { | |
| 291 | + | if entry.FaviconUrl.Valid && entry.FaviconUrl.String != "" { | |
| 292 | + | return entry.FaviconUrl.String, nil | |
| 293 | + | } | |
| 294 | + | } else if err != sql.ErrNoRows { | |
| 295 | + | log.Printf("[favicon] failed to query history entry for %s: %v", pageURL, err) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | if fs.faviconDB != nil { | |
| 299 | + | if uri := fs.faviconDB.FaviconURI(pageURL); uri != "" { | |
| 300 | + | return uri, nil | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | return "", fmt.Errorf("no favicon URI available for %s", pageURL) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func (fs *FaviconService) ensureTextureSize(texture *gdk.Texture) *gdk.Texture { | |
| 308 | + | if texture == nil { | |
| 309 | + | return nil | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | if texture.Width() == fs.targetSize && texture.Height() == fs.targetSize { | |
| 313 | + | return texture | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | return webkit.ScaleTexture(texture, fs.targetSize, fs.targetSize) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func (fs *FaviconService) dispatchTexture(callback func(*gdk.Texture, error), texture *gdk.Texture, err error) { | |
| 320 | + | _ = webkit.IdleAdd(func() bool { | |
| 321 | + | callback(texture, err) | |
| 322 | + | return false | |
| 323 | + | }) | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func (fs *FaviconService) maybeExportTexture(pageURL string, texture *gdk.Texture) { | |
| 327 | + | if !fs.enableExport || texture == nil { | |
| 328 | + | return | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | exportPath := fs.getExportPath(pageURL) | |
| 332 | + | ||
| 333 | + | _ = webkit.IdleAdd(func() bool { | |
| 334 | + | if err := webkit.SaveTextureAsPNG(texture, exportPath, fs.targetSize); err != nil { | |
| 335 | + | log.Printf("[favicon] Failed to export favicon PNG for %s: %v", pageURL, err) | |
| 336 | + | } | |
| 337 | + | return false | |
| 338 | + | }) | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | // GetFaviconPath returns the exported PNG path for CLI tools (dmenu, etc.) | |
| 342 | + | // This allows CLI tools to display favicons without WebKitGTK API access. | |
| 343 | + | func (fs *FaviconService) GetFaviconPath(pageURL string) (string, error) { | |
| 344 | + | exportPath := fs.getExportPath(pageURL) | |
| 345 | + | if info, err := os.Stat(exportPath); err == nil { | |
| 346 | + | if time.Since(info.ModTime()) < 7*24*time.Hour { | |
| 347 | + | return exportPath, nil | |
| 348 | + | } | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | if !fs.enableExport { | |
| 352 | + | return "", fmt.Errorf("favicon export disabled") | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout) | |
| 356 | + | defer cancel() | |
| 357 | + | ||
| 358 | + | faviconURI, err := fs.lookupFaviconURI(ctx, pageURL) | |
| 359 | + | if err != nil { | |
| 360 | + | return "", err | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI) | |
| 364 | + | if err != nil { | |
| 365 | + | return "", err | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | if err := webkit.SaveTextureAsPNG(texture, exportPath, fs.targetSize); err != nil { | |
| 369 | + | return "", fmt.Errorf("failed to save favicon PNG: %w", err) | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | return exportPath, nil | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | // getExportPath generates the export path for a page URL | |
| 376 | + | func (fs *FaviconService) getExportPath(pageURL string) string { | |
| 377 | + | // Use the same hashing scheme as the old cache for compatibility | |
| 378 | + | hash := fmt.Sprintf("%x", webkit.HashURL(pageURL)) | |
| 379 | + | return filepath.Join(fs.exportDir, hash+".png") | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | // shouldProcessURL checks if a URL should have its favicon processed | |
| 383 | + | func (fs *FaviconService) shouldProcessURL(pageURL string) bool { | |
| 384 | + | parsedURL, err := webkit.ParseURL(pageURL) | |
| 385 | + | if err != nil { | |
| 386 | + | log.Printf("[favicon] Invalid page URL: %s", pageURL) | |
| 387 | + | return false | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { | |
| 391 | + | return false | |
| 392 | + | } | |
| 393 | + | if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" { | |
| 394 | + | return false | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | return true | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // isSVGFavicon checks if a favicon URI points to an SVG file. | |
| 401 | + | func (fs *FaviconService) isSVGFavicon(faviconURI string) bool { | |
| 402 | + | if faviconURI == "" { | |
| 403 | + | return false | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | parsedURL, err := webkit.ParseURL(faviconURI) | |
| 407 | + | if err != nil { | |
| 408 | + | return false | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | path := parsedURL.Path | |
| 412 | + | if len(path) >= 4 { | |
| 413 | + | ext := path[len(path)-4:] | |
| 414 | + | if ext == ".svg" || ext == ".SVG" { | |
| 415 | + | return true | |
| 416 | + | } | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | return false | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | // CleanOldExports removes exported favicons older than 30 days | |
| 423 | + | func (fs *FaviconService) CleanOldExports() error { | |
| 424 | + | entries, err := os.ReadDir(fs.exportDir) | |
| 425 | + | if err != nil { | |
| 426 | + | return err | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | cutoff := time.Now().Add(-30 * 24 * time.Hour) | |
| 430 | + | ||
| 431 | + | for _, entry := range entries { | |
| 432 | + | if entry.IsDir() { | |
| 433 | + | continue | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | info, err := entry.Info() | |
| 437 | + | if err != nil { | |
| 438 | + | continue | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | if info.ModTime().Before(cutoff) { | |
| 442 | + | os.Remove(filepath.Join(fs.exportDir, entry.Name())) | |
| 443 | + | } | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | return nil | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | // GetExportedFaviconPath returns the expected export path for a page URL. | |
| 450 | + | func GetExportedFaviconPath(dataDir, pageURL string) string { | |
| 451 | + | exportDir := filepath.Join(dataDir, "webkit", "favicons-export") | |
| 452 | + | hash := fmt.Sprintf("%x", webkit.HashURL(pageURL)) | |
| 453 | + | return filepath.Join(exportDir, hash+".png") | |
| 454 | + | } | |
| @@ -0,0 +1,454 @@ | |||||
| 1 | + | // Package services contains application services that orchestrate business logic. | |||
| 2 | + | package services | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "database/sql" | |||
| 7 | + | "fmt" | |||
| 8 | + | "log" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/dumber/internal/db" | |||
| 14 | + | "github.com/bnema/dumber/pkg/webkit" | |||
| 15 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |||
| 16 | + | "github.com/diamondburned/gotk4/pkg/gio/v2" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // FaviconService provides unified favicon access using WebKitGTK's native database. | |||
| 20 | + | // The service keeps WebKit's FaviconDatabase as the source of truth and augments it | |||
| 21 | + | // with fallback rasterisation for SVG favicons so that callers always receive a | |||
| 22 | + | // 32x32 texture or an explicit error. | |||
| 23 | + | type FaviconService struct { | |||
| 24 | + | faviconDB *webkit.FaviconDatabase | |||
| 25 | + | dbQueries db.DatabaseQuerier | |||
| 26 | + | targetSize int // Target size for exported favicons (32x32 for high quality) | |||
| 27 | + | exportDir string // Directory for exporting favicons for CLI access | |||
| 28 | + | dataDir string // WebKit data directory | |||
| 29 | + | enableExport bool // Whether to export favicons for CLI tools | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | const ( | |||
| 33 | + | // DefaultFaviconSize is the target size for exported favicons (32x32 for high quality) | |||
| 34 | + | DefaultFaviconSize = 32 | |||
| 35 | + | // FaviconTimeout is the maximum time to wait for a favicon to be available | |||
| 36 | + | FaviconTimeout = 5 * time.Second | |||
| 37 | + | ) | |||
| 38 | + | ||||
| 39 | + | // ServiceName returns the service name for identification | |||
| 40 | + | func (fs *FaviconService) ServiceName() string { | |||
| 41 | + | return "FaviconService" | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // NewFaviconService creates a new favicon service | |||
| 45 | + | func NewFaviconService(faviconDB *webkit.FaviconDatabase, queries db.DatabaseQuerier, dataDir string) (*FaviconService, error) { | |||
| 46 | + | if faviconDB == nil { | |||
| 47 | + | return nil, fmt.Errorf("favicon database cannot be nil") | |||
| 48 | + | } | |||
| 49 | + | if queries == nil { | |||
| 50 | + | return nil, fmt.Errorf("database queries cannot be nil") | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // Create export directory for CLI access | |||
| 54 | + | exportDir := filepath.Join(dataDir, "favicons-export") | |||
| 55 | + | if err := os.MkdirAll(exportDir, 0755); err != nil { | |||
| 56 | + | return nil, fmt.Errorf("failed to create favicon export directory: %w", err) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | fs := &FaviconService{ | |||
| 60 | + | faviconDB: faviconDB, | |||
| 61 | + | dbQueries: queries, | |||
| 62 | + | targetSize: DefaultFaviconSize, | |||
| 63 | + | exportDir: exportDir, | |||
| 64 | + | dataDir: dataDir, | |||
| 65 | + | enableExport: true, | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // Register the favicon changed handler ONCE at the service level | |||
| 69 | + | // This is critical to avoid duplicate handlers when multiple WebViews exist | |||
| 70 | + | // The FaviconDatabase is shared across all WebViews (at NetworkSession level) | |||
| 71 | + | faviconDB.ConnectFaviconChanged(func(pageURI, faviconURI string) { | |||
| 72 | + | if err := fs.OnFaviconChanged(pageURI, faviconURI); err != nil { | |||
| 73 | + | log.Printf("[favicon] Handler error for %s: %v", pageURI, err) | |||
| 74 | + | } | |||
| 75 | + | }) | |||
| 76 | + | ||||
| 77 | + | log.Printf("[favicon] Registered single shared favicon handler at FaviconService level") | |||
| 78 | + | ||||
| 79 | + | return fs, nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // OnFaviconChanged handles favicon URI changes from WebKit's favicon database. | |||
| 83 | + | // We store the URI (PNG, SVG, etc.) so that callers can later load an appropriate texture. | |||
| 84 | + | func (fs *FaviconService) OnFaviconChanged(pageURL, faviconURI string) error { | |||
| 85 | + | log.Printf("[favicon] Favicon changed for %s: %s", pageURL, faviconURI) | |||
| 86 | + | ||||
| 87 | + | ctx := context.Background() | |||
| 88 | + | ||||
| 89 | + | if !fs.shouldProcessURL(pageURL) { | |||
| 90 | + | return nil | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | nullString := sql.NullString{String: faviconURI, Valid: faviconURI != ""} | |||
| 94 | + | if err := fs.dbQueries.UpdateHistoryFavicon(ctx, nullString, pageURL); err != nil { | |||
| 95 | + | log.Printf("[favicon] Failed to update favicon URI in database for %s: %v", pageURL, err) | |||
| 96 | + | return fmt.Errorf("failed to update favicon in database: %w", err) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // Also update all other URLs from the same domain to propagate favicon | |||
| 100 | + | // This handles multiple paths on the same domain (e.g., google.com/search?q=foo and google.com/search?q=bar) | |||
| 101 | + | fs.propagateFaviconToDomain(pageURL, faviconURI) | |||
| 102 | + | ||||
| 103 | + | // Proactively render and export favicon for omnibox/CLI use | |||
| 104 | + | // This ensures favicons are available ASAP for suggestions | |||
| 105 | + | if fs.enableExport { | |||
| 106 | + | go fs.preloadFavicon(pageURL, faviconURI) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | return nil | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // propagateFaviconToDomain updates all history entries from the same domain | |||
| 113 | + | // with the favicon. This ensures multiple paths on the same domain share the favicon | |||
| 114 | + | // even if WebKit only signals it once per domain (KISS + DRY principle). | |||
| 115 | + | func (fs *FaviconService) propagateFaviconToDomain(pageURL, faviconURI string) { | |||
| 116 | + | ctx := context.Background() | |||
| 117 | + | ||||
| 118 | + | parsedURL, err := webkit.ParseURL(pageURL) | |||
| 119 | + | if err != nil { | |||
| 120 | + | return // Invalid URL, skip propagation | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | faviconNull := sql.NullString{String: faviconURI, Valid: true} | |||
| 124 | + | ||||
| 125 | + | // Search for exact domain match first | |||
| 126 | + | domainPattern := sql.NullString{String: "%://" + parsedURL.Host + "%", Valid: true} | |||
| 127 | + | entries, err := fs.dbQueries.SearchHistory(ctx, domainPattern, sql.NullString{}, 1000) | |||
| 128 | + | if err != nil { | |||
| 129 | + | log.Printf("[favicon] Failed to search history for domain %s: %v", parsedURL.Host, err) | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // Also search for www variant to handle www/non-www redirects | |||
| 134 | + | // google.com should share favicon with www.google.com and vice versa | |||
| 135 | + | var altPattern sql.NullString | |||
| 136 | + | if len(parsedURL.Host) > 4 && parsedURL.Host[:4] == "www." { | |||
| 137 | + | // Current is www.google.com, also search google.com | |||
| 138 | + | hostWithoutWWW := parsedURL.Host[4:] | |||
| 139 | + | altPattern = sql.NullString{String: "%://" + hostWithoutWWW + "%", Valid: true} | |||
| 140 | + | } else { | |||
| 141 | + | // Current is google.com, also search www.google.com | |||
| 142 | + | altPattern = sql.NullString{String: "%://www." + parsedURL.Host + "%", Valid: true} | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | altEntries, _ := fs.dbQueries.SearchHistory(ctx, altPattern, sql.NullString{}, 1000) | |||
| 146 | + | entries = append(entries, altEntries...) | |||
| 147 | + | ||||
| 148 | + | // Update all entries that don't already have a favicon | |||
| 149 | + | for _, entry := range entries { | |||
| 150 | + | if entry.FaviconUrl.String == "" { | |||
| 151 | + | if err := fs.dbQueries.UpdateHistoryFavicon(ctx, faviconNull, entry.Url); err != nil { | |||
| 152 | + | log.Printf("[favicon] Failed to propagate favicon to %s: %v", entry.Url, err) | |||
| 153 | + | } else { | |||
| 154 | + | log.Printf("[favicon] Propagated favicon to %s", entry.Url) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // preloadFavicon renders and exports a favicon in the background. | |||
| 161 | + | // This is called immediately when OnFaviconChanged fires to ensure | |||
| 162 | + | // favicons are ready for omnibox suggestions as soon as possible. | |||
| 163 | + | func (fs *FaviconService) preloadFavicon(pageURL, faviconURI string) { | |||
| 164 | + | ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout) | |||
| 165 | + | defer cancel() | |||
| 166 | + | ||||
| 167 | + | texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI) | |||
| 168 | + | if err != nil { | |||
| 169 | + | log.Printf("[favicon] Preload failed for %s: %v", pageURL, err) | |||
| 170 | + | return | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | fs.maybeExportTexture(pageURL, texture) | |||
| 174 | + | log.Printf("[favicon] Preloaded and exported favicon for %s", pageURL) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // GetFaviconTexture loads a favicon texture asynchronously and calls the callback with the result. | |||
| 178 | + | // The callback receives a 32x32 GdkTexture suitable for display in GTK widgets, or an error. | |||
| 179 | + | func (fs *FaviconService) GetFaviconTexture(pageURL string, callback func(*gdk.Texture, error)) { | |||
| 180 | + | if callback == nil { | |||
| 181 | + | return | |||
| 182 | + | } | |||
| 183 | + | if pageURL == "" { | |||
| 184 | + | fs.dispatchTexture(callback, nil, fmt.Errorf("page URL is empty")) | |||
| 185 | + | return | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | go func() { | |||
| 189 | + | ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout) | |||
| 190 | + | defer cancel() | |||
| 191 | + | ||||
| 192 | + | faviconURI, err := fs.lookupFaviconURI(ctx, pageURL) | |||
| 193 | + | if err != nil { | |||
| 194 | + | fs.dispatchTexture(callback, nil, err) | |||
| 195 | + | return | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI) | |||
| 199 | + | if err != nil { | |||
| 200 | + | fs.dispatchTexture(callback, nil, err) | |||
| 201 | + | return | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | fs.maybeExportTexture(pageURL, texture) | |||
| 205 | + | ||||
| 206 | + | fs.dispatchTexture(callback, texture, nil) | |||
| 207 | + | }() | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func (fs *FaviconService) renderFaviconTexture(ctx context.Context, pageURL, faviconURI string) (*gdk.Texture, error) { | |||
| 211 | + | if faviconURI == "" { | |||
| 212 | + | return nil, fmt.Errorf("no favicon URI available") | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | if fs.isSVGFavicon(faviconURI) { | |||
| 216 | + | return fs.loadSVGFaviconTexture(ctx, faviconURI) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | if fs.faviconDB == nil { | |||
| 220 | + | return nil, fmt.Errorf("favicon database not initialized") | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | type result struct { | |||
| 224 | + | texture *gdk.Texture | |||
| 225 | + | err error | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | resultCh := make(chan result, 1) | |||
| 229 | + | ||||
| 230 | + | fs.faviconDB.Favicon(ctx, pageURL, func(res gio.AsyncResulter) { | |||
| 231 | + | if res == nil { | |||
| 232 | + | select { | |||
| 233 | + | case resultCh <- result{err: fmt.Errorf("favicon async result is nil")}: | |||
| 234 | + | default: | |||
| 235 | + | } | |||
| 236 | + | return | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | texture, err := webkit.FaviconFinishSafe(fs.faviconDB, res) | |||
| 240 | + | if err == nil && texture == nil { | |||
| 241 | + | err = fmt.Errorf("favicon unavailable") | |||
| 242 | + | } | |||
| 243 | + | if err == nil { | |||
| 244 | + | texture = fs.ensureTextureSize(texture) | |||
| 245 | + | if texture == nil { | |||
| 246 | + | err = fmt.Errorf("failed to scale favicon texture") | |||
| 247 | + | } | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | select { | |||
| 251 | + | case resultCh <- result{texture: texture, err: err}: | |||
| 252 | + | default: | |||
| 253 | + | } | |||
| 254 | + | }) | |||
| 255 | + | ||||
| 256 | + | select { | |||
| 257 | + | case <-ctx.Done(): | |||
| 258 | + | return nil, ctx.Err() | |||
| 259 | + | case res := <-resultCh: | |||
| 260 | + | return res.texture, res.err | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func (fs *FaviconService) loadSVGFaviconTexture(ctx context.Context, faviconURI string) (*gdk.Texture, error) { | |||
| 265 | + | file := gio.NewFileForURI(faviconURI) | |||
| 266 | + | if file == nil { | |||
| 267 | + | return nil, fmt.Errorf("invalid SVG favicon URI: %s", faviconURI) | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | data, _, err := file.LoadContents(ctx) | |||
| 271 | + | if err != nil { | |||
| 272 | + | return nil, fmt.Errorf("load SVG favicon: %w", err) | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | texture, err := webkit.TextureFromImageBytes(data) | |||
| 276 | + | if err != nil { | |||
| 277 | + | return nil, fmt.Errorf("decode SVG favicon: %w", err) | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | texture = fs.ensureTextureSize(texture) | |||
| 281 | + | if texture == nil { | |||
| 282 | + | return nil, fmt.Errorf("failed to scale SVG favicon") | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | return texture, nil | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func (fs *FaviconService) lookupFaviconURI(ctx context.Context, pageURL string) (string, error) { | |||
| 289 | + | entry, err := fs.dbQueries.GetHistoryEntry(ctx, pageURL) | |||
| 290 | + | if err == nil { | |||
| 291 | + | if entry.FaviconUrl.Valid && entry.FaviconUrl.String != "" { | |||
| 292 | + | return entry.FaviconUrl.String, nil | |||
| 293 | + | } | |||
| 294 | + | } else if err != sql.ErrNoRows { | |||
| 295 | + | log.Printf("[favicon] failed to query history entry for %s: %v", pageURL, err) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | if fs.faviconDB != nil { | |||
| 299 | + | if uri := fs.faviconDB.FaviconURI(pageURL); uri != "" { | |||
| 300 | + | return uri, nil | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | return "", fmt.Errorf("no favicon URI available for %s", pageURL) | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func (fs *FaviconService) ensureTextureSize(texture *gdk.Texture) *gdk.Texture { | |||
| 308 | + | if texture == nil { | |||
| 309 | + | return nil | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | if texture.Width() == fs.targetSize && texture.Height() == fs.targetSize { | |||
| 313 | + | return texture | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | return webkit.ScaleTexture(texture, fs.targetSize, fs.targetSize) | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | func (fs *FaviconService) dispatchTexture(callback func(*gdk.Texture, error), texture *gdk.Texture, err error) { | |||
| 320 | + | _ = webkit.IdleAdd(func() bool { | |||
| 321 | + | callback(texture, err) | |||
| 322 | + | return false | |||
| 323 | + | }) | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func (fs *FaviconService) maybeExportTexture(pageURL string, texture *gdk.Texture) { | |||
| 327 | + | if !fs.enableExport || texture == nil { | |||
| 328 | + | return | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | exportPath := fs.getExportPath(pageURL) | |||
| 332 | + | ||||
| 333 | + | _ = webkit.IdleAdd(func() bool { | |||
| 334 | + | if err := webkit.SaveTextureAsPNG(texture, exportPath, fs.targetSize); err != nil { | |||
| 335 | + | log.Printf("[favicon] Failed to export favicon PNG for %s: %v", pageURL, err) | |||
| 336 | + | } | |||
| 337 | + | return false | |||
| 338 | + | }) | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | // GetFaviconPath returns the exported PNG path for CLI tools (dmenu, etc.) | |||
| 342 | + | // This allows CLI tools to display favicons without WebKitGTK API access. | |||
| 343 | + | func (fs *FaviconService) GetFaviconPath(pageURL string) (string, error) { | |||
| 344 | + | exportPath := fs.getExportPath(pageURL) | |||
| 345 | + | if info, err := os.Stat(exportPath); err == nil { | |||
| 346 | + | if time.Since(info.ModTime()) < 7*24*time.Hour { | |||
| 347 | + | return exportPath, nil | |||
| 348 | + | } | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | if !fs.enableExport { | |||
| 352 | + | return "", fmt.Errorf("favicon export disabled") | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | ctx, cancel := context.WithTimeout(context.Background(), FaviconTimeout) | |||
| 356 | + | defer cancel() | |||
| 357 | + | ||||
| 358 | + | faviconURI, err := fs.lookupFaviconURI(ctx, pageURL) | |||
| 359 | + | if err != nil { | |||
| 360 | + | return "", err | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | texture, err := fs.renderFaviconTexture(ctx, pageURL, faviconURI) | |||
| 364 | + | if err != nil { | |||
| 365 | + | return "", err | |||
| 366 | + | } | |||
| 367 | + | ||||
| 368 | + | if err := webkit.SaveTextureAsPNG(texture, exportPath, fs.targetSize); err != nil { | |||
| 369 | + | return "", fmt.Errorf("failed to save favicon PNG: %w", err) | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | return exportPath, nil | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | // getExportPath generates the export path for a page URL | |||
| 376 | + | func (fs *FaviconService) getExportPath(pageURL string) string { | |||
| 377 | + | // Use the same hashing scheme as the old cache for compatibility | |||
| 378 | + | hash := fmt.Sprintf("%x", webkit.HashURL(pageURL)) | |||
| 379 | + | return filepath.Join(fs.exportDir, hash+".png") | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | // shouldProcessURL checks if a URL should have its favicon processed | |||
| 383 | + | func (fs *FaviconService) shouldProcessURL(pageURL string) bool { | |||
| 384 | + | parsedURL, err := webkit.ParseURL(pageURL) | |||
| 385 | + | if err != nil { | |||
| 386 | + | log.Printf("[favicon] Invalid page URL: %s", pageURL) | |||
| 387 | + | return false | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { | |||
| 391 | + | return false | |||
| 392 | + | } | |||
| 393 | + | if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" { | |||
| 394 | + | return false | |||
| 395 | + | } | |||
| 396 | + | ||||
| 397 | + | return true | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | // isSVGFavicon checks if a favicon URI points to an SVG file. | |||
| 401 | + | func (fs *FaviconService) isSVGFavicon(faviconURI string) bool { | |||
| 402 | + | if faviconURI == "" { | |||
| 403 | + | return false | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | parsedURL, err := webkit.ParseURL(faviconURI) | |||
| 407 | + | if err != nil { | |||
| 408 | + | return false | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | path := parsedURL.Path | |||
| 412 | + | if len(path) >= 4 { | |||
| 413 | + | ext := path[len(path)-4:] | |||
| 414 | + | if ext == ".svg" || ext == ".SVG" { | |||
| 415 | + | return true | |||
| 416 | + | } | |||
| 417 | + | } | |||
| 418 | + | ||||
| 419 | + | return false | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | // CleanOldExports removes exported favicons older than 30 days | |||
| 423 | + | func (fs *FaviconService) CleanOldExports() error { | |||
| 424 | + | entries, err := os.ReadDir(fs.exportDir) | |||
| 425 | + | if err != nil { | |||
| 426 | + | return err | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | cutoff := time.Now().Add(-30 * 24 * time.Hour) | |||
| 430 | + | ||||
| 431 | + | for _, entry := range entries { | |||
| 432 | + | if entry.IsDir() { | |||
| 433 | + | continue | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | info, err := entry.Info() | |||
| 437 | + | if err != nil { | |||
| 438 | + | continue | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | if info.ModTime().Before(cutoff) { | |||
| 442 | + | os.Remove(filepath.Join(fs.exportDir, entry.Name())) | |||
| 443 | + | } | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | return nil | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | // GetExportedFaviconPath returns the expected export path for a page URL. | |||
| 450 | + | func GetExportedFaviconPath(dataDir, pageURL string) string { | |||
| 451 | + | exportDir := filepath.Join(dataDir, "webkit", "favicons-export") | |||
| 452 | + | hash := fmt.Sprintf("%x", webkit.HashURL(pageURL)) | |||
| 453 | + | return filepath.Join(exportDir, hash+".png") | |||
| 454 | + | } | |||
A internal/services/service.go +33 -0
| @@ -0,0 +1,33 @@ | |||
| 1 | + | // Package services defines the service interface and common service patterns | |
| 2 | + | package services | |
| 3 | + | ||
| 4 | + | // Service is the base interface that all application services must implement | |
| 5 | + | // This allows for consistent service management, lifecycle control, and dependency injection | |
| 6 | + | type Service interface { | |
| 7 | + | // ServiceName returns the unique identifier for this service | |
| 8 | + | // Used for logging, debugging, and service registration | |
| 9 | + | ServiceName() string | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | // Initializable services can be initialized after construction | |
| 13 | + | type Initializable interface { | |
| 14 | + | Service | |
| 15 | + | // Initialize performs any setup required after construction | |
| 16 | + | // Returns an error if initialization fails | |
| 17 | + | Initialize() error | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // Cleanable services can cleanup resources on shutdown | |
| 21 | + | type Cleanable interface { | |
| 22 | + | Service | |
| 23 | + | // Cleanup releases any resources held by the service | |
| 24 | + | // Should be idempotent and safe to call multiple times | |
| 25 | + | Cleanup() error | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // LifecycleService implements both Initializable and Cleanable | |
| 29 | + | // Use this for services that need both initialization and cleanup | |
| 30 | + | type LifecycleService interface { | |
| 31 | + | Initializable | |
| 32 | + | Cleanable | |
| 33 | + | } | |
| @@ -0,0 +1,33 @@ | |||||
| 1 | + | // Package services defines the service interface and common service patterns | |||
| 2 | + | package services | |||
| 3 | + | ||||
| 4 | + | // Service is the base interface that all application services must implement | |||
| 5 | + | // This allows for consistent service management, lifecycle control, and dependency injection | |||
| 6 | + | type Service interface { | |||
| 7 | + | // ServiceName returns the unique identifier for this service | |||
| 8 | + | // Used for logging, debugging, and service registration | |||
| 9 | + | ServiceName() string | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | // Initializable services can be initialized after construction | |||
| 13 | + | type Initializable interface { | |||
| 14 | + | Service | |||
| 15 | + | // Initialize performs any setup required after construction | |||
| 16 | + | // Returns an error if initialization fails | |||
| 17 | + | Initialize() error | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // Cleanable services can cleanup resources on shutdown | |||
| 21 | + | type Cleanable interface { | |||
| 22 | + | Service | |||
| 23 | + | // Cleanup releases any resources held by the service | |||
| 24 | + | // Should be idempotent and safe to call multiple times | |||
| 25 | + | Cleanup() error | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // LifecycleService implements both Initializable and Cleanable | |||
| 29 | + | // Use this for services that need both initialization and cleanup | |||
| 30 | + | type LifecycleService interface { | |||
| 31 | + | Initializable | |||
| 32 | + | Cleanable | |||
| 33 | + | } | |||
A pkg/webkit/favicon_helpers.go +163 -0
| @@ -0,0 +1,163 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | /* | |
| 4 | + | #cgo pkg-config: webkitgtk-6.0 | |
| 5 | + | #include <webkit/webkit.h> | |
| 6 | + | */ | |
| 7 | + | import "C" | |
| 8 | + | ||
| 9 | + | import ( | |
| 10 | + | "context" | |
| 11 | + | "crypto/md5" | |
| 12 | + | "fmt" | |
| 13 | + | "net/url" | |
| 14 | + | "runtime" | |
| 15 | + | "unsafe" | |
| 16 | + | ||
| 17 | + | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | |
| 18 | + | "github.com/diamondburned/gotk4/pkg/core/gerror" | |
| 19 | + | coreglib "github.com/diamondburned/gotk4/pkg/core/glib" | |
| 20 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |
| 21 | + | "github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2" | |
| 22 | + | gio "github.com/diamondburned/gotk4/pkg/gio/v2" | |
| 23 | + | "github.com/diamondburned/gotk4/pkg/glib/v2" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // GdkTexture is an alias for gotk4's Texture type | |
| 27 | + | type GdkTexture = gdk.Texture | |
| 28 | + | ||
| 29 | + | // FaviconDatabase is an alias for WebKit's FaviconDatabase | |
| 30 | + | type FaviconDatabase = webkit.FaviconDatabase | |
| 31 | + | ||
| 32 | + | // SaveTextureAsPNG saves a GdkTexture as a PNG file at target size | |
| 33 | + | // WebKit provides GdkTexture which can be any format (ICO, PNG, JPEG, SVG) | |
| 34 | + | // This function normalizes everything to PNG at the target size (32x32) | |
| 35 | + | func SaveTextureAsPNG(texture *gdk.Texture, path string, targetSize int) error { | |
| 36 | + | if texture == nil { | |
| 37 | + | return fmt.Errorf("texture cannot be nil") | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | width := texture.Width() | |
| 41 | + | height := texture.Height() | |
| 42 | + | ||
| 43 | + | // If texture needs scaling, create scaled version | |
| 44 | + | if width != targetSize || height != targetSize { | |
| 45 | + | texture = ScaleTexture(texture, targetSize, targetSize) | |
| 46 | + | if texture == nil { | |
| 47 | + | return fmt.Errorf("failed to scale texture") | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // Save texture as PNG using built-in method | |
| 52 | + | if !texture.SaveToPNG(path) { | |
| 53 | + | return fmt.Errorf("failed to save PNG to %s", path) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // ScaleTexture scales a texture to the target size using GdkPixbuf | |
| 60 | + | // Uses high-quality bilinear interpolation | |
| 61 | + | func ScaleTexture(texture *gdk.Texture, targetWidth, targetHeight int) *gdk.Texture { | |
| 62 | + | if texture == nil { | |
| 63 | + | return nil | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Get PNG bytes from texture | |
| 67 | + | pngBytes := texture.SaveToPNGBytes() | |
| 68 | + | if pngBytes == nil { | |
| 69 | + | return nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Load as pixbuf using memory input stream | |
| 73 | + | ctx := context.Background() | |
| 74 | + | stream := gio.NewMemoryInputStreamFromBytes(pngBytes) | |
| 75 | + | pixbuf, err := gdkpixbuf.NewPixbufFromStream(ctx, stream) | |
| 76 | + | if err != nil || pixbuf == nil { | |
| 77 | + | return nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // Scale pixbuf | |
| 81 | + | scaled := pixbuf.ScaleSimple(targetWidth, targetHeight, gdkpixbuf.InterpBilinear) | |
| 82 | + | if scaled == nil { | |
| 83 | + | return nil | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Convert back to texture | |
| 87 | + | return gdk.NewTextureForPixbuf(scaled) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // HashURL generates an MD5 hash of a URL for filename generation | |
| 91 | + | func HashURL(pageURL string) []byte { | |
| 92 | + | hash := md5.Sum([]byte(pageURL)) | |
| 93 | + | return hash[:] | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // ParseURL is a convenience wrapper around url.Parse | |
| 97 | + | func ParseURL(rawURL string) (*url.URL, error) { | |
| 98 | + | return url.Parse(rawURL) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // FaviconFinishSafe mirrors gotk4's FaviconFinish but guards against NULL returns. | |
| 102 | + | func FaviconFinishSafe(database *webkit.FaviconDatabase, result gio.AsyncResulter) (*gdk.Texture, error) { | |
| 103 | + | if database == nil || result == nil { | |
| 104 | + | return nil, fmt.Errorf("favicon finish: invalid arguments") | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | cDatabase := (*C.WebKitFaviconDatabase)(unsafe.Pointer(glib.BaseObject(database).Native())) | |
| 108 | + | cResult := (*C.GAsyncResult)(unsafe.Pointer(glib.BaseObject(result).Native())) | |
| 109 | + | ||
| 110 | + | var cErr *C.GError | |
| 111 | + | cTexture := C.webkit_favicon_database_get_favicon_finish(cDatabase, cResult, &cErr) | |
| 112 | + | runtime.KeepAlive(database) | |
| 113 | + | runtime.KeepAlive(result) | |
| 114 | + | ||
| 115 | + | if cErr != nil { | |
| 116 | + | err := gerror.Take(unsafe.Pointer(cErr)) | |
| 117 | + | return nil, err | |
| 118 | + | } | |
| 119 | + | if cTexture == nil { | |
| 120 | + | return nil, nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | object := coreglib.AssumeOwnership(unsafe.Pointer(cTexture)) | |
| 124 | + | casted := object.WalkCast(func(obj coreglib.Objector) bool { | |
| 125 | + | _, ok := obj.(gdk.Texturer) | |
| 126 | + | return ok | |
| 127 | + | }) | |
| 128 | + | ||
| 129 | + | texturer, ok := casted.(gdk.Texturer) | |
| 130 | + | if !ok { | |
| 131 | + | return nil, fmt.Errorf("favicon finish: unexpected type %T", casted) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | texture := gdk.BaseTexture(texturer) | |
| 135 | + | if texture == nil { | |
| 136 | + | return nil, fmt.Errorf("favicon finish: texture unavailable") | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | return texture, nil | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // TextureFromImageBytes creates a texture from raw image bytes. | |
| 143 | + | // Supported formats depend on the available GdkPixbuf loaders (PNG, SVG, etc.). | |
| 144 | + | func TextureFromImageBytes(data []byte) (*gdk.Texture, error) { | |
| 145 | + | if len(data) == 0 { | |
| 146 | + | return nil, fmt.Errorf("image data is empty") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Convert []byte to glib.Bytes for gio API | |
| 150 | + | glibBytes := glib.NewBytes(data) | |
| 151 | + | stream := gio.NewMemoryInputStreamFromBytes(glibBytes) | |
| 152 | + | pixbuf, err := gdkpixbuf.NewPixbufFromStream(context.Background(), stream) | |
| 153 | + | if err != nil { | |
| 154 | + | return nil, fmt.Errorf("load pixbuf: %w", err) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | texture := gdk.NewTextureForPixbuf(pixbuf) | |
| 158 | + | if texture == nil { | |
| 159 | + | return nil, fmt.Errorf("create texture from pixbuf failed") | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | return texture, nil | |
| 163 | + | } | |
| @@ -0,0 +1,163 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | /* | |||
| 4 | + | #cgo pkg-config: webkitgtk-6.0 | |||
| 5 | + | #include <webkit/webkit.h> | |||
| 6 | + | */ | |||
| 7 | + | import "C" | |||
| 8 | + | ||||
| 9 | + | import ( | |||
| 10 | + | "context" | |||
| 11 | + | "crypto/md5" | |||
| 12 | + | "fmt" | |||
| 13 | + | "net/url" | |||
| 14 | + | "runtime" | |||
| 15 | + | "unsafe" | |||
| 16 | + | ||||
| 17 | + | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | |||
| 18 | + | "github.com/diamondburned/gotk4/pkg/core/gerror" | |||
| 19 | + | coreglib "github.com/diamondburned/gotk4/pkg/core/glib" | |||
| 20 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |||
| 21 | + | "github.com/diamondburned/gotk4/pkg/gdkpixbuf/v2" | |||
| 22 | + | gio "github.com/diamondburned/gotk4/pkg/gio/v2" | |||
| 23 | + | "github.com/diamondburned/gotk4/pkg/glib/v2" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // GdkTexture is an alias for gotk4's Texture type | |||
| 27 | + | type GdkTexture = gdk.Texture | |||
| 28 | + | ||||
| 29 | + | // FaviconDatabase is an alias for WebKit's FaviconDatabase | |||
| 30 | + | type FaviconDatabase = webkit.FaviconDatabase | |||
| 31 | + | ||||
| 32 | + | // SaveTextureAsPNG saves a GdkTexture as a PNG file at target size | |||
| 33 | + | // WebKit provides GdkTexture which can be any format (ICO, PNG, JPEG, SVG) | |||
| 34 | + | // This function normalizes everything to PNG at the target size (32x32) | |||
| 35 | + | func SaveTextureAsPNG(texture *gdk.Texture, path string, targetSize int) error { | |||
| 36 | + | if texture == nil { | |||
| 37 | + | return fmt.Errorf("texture cannot be nil") | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | width := texture.Width() | |||
| 41 | + | height := texture.Height() | |||
| 42 | + | ||||
| 43 | + | // If texture needs scaling, create scaled version | |||
| 44 | + | if width != targetSize || height != targetSize { | |||
| 45 | + | texture = ScaleTexture(texture, targetSize, targetSize) | |||
| 46 | + | if texture == nil { | |||
| 47 | + | return fmt.Errorf("failed to scale texture") | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // Save texture as PNG using built-in method | |||
| 52 | + | if !texture.SaveToPNG(path) { | |||
| 53 | + | return fmt.Errorf("failed to save PNG to %s", path) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | return nil | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // ScaleTexture scales a texture to the target size using GdkPixbuf | |||
| 60 | + | // Uses high-quality bilinear interpolation | |||
| 61 | + | func ScaleTexture(texture *gdk.Texture, targetWidth, targetHeight int) *gdk.Texture { | |||
| 62 | + | if texture == nil { | |||
| 63 | + | return nil | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Get PNG bytes from texture | |||
| 67 | + | pngBytes := texture.SaveToPNGBytes() | |||
| 68 | + | if pngBytes == nil { | |||
| 69 | + | return nil | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // Load as pixbuf using memory input stream | |||
| 73 | + | ctx := context.Background() | |||
| 74 | + | stream := gio.NewMemoryInputStreamFromBytes(pngBytes) | |||
| 75 | + | pixbuf, err := gdkpixbuf.NewPixbufFromStream(ctx, stream) | |||
| 76 | + | if err != nil || pixbuf == nil { | |||
| 77 | + | return nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // Scale pixbuf | |||
| 81 | + | scaled := pixbuf.ScaleSimple(targetWidth, targetHeight, gdkpixbuf.InterpBilinear) | |||
| 82 | + | if scaled == nil { | |||
| 83 | + | return nil | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // Convert back to texture | |||
| 87 | + | return gdk.NewTextureForPixbuf(scaled) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // HashURL generates an MD5 hash of a URL for filename generation | |||
| 91 | + | func HashURL(pageURL string) []byte { | |||
| 92 | + | hash := md5.Sum([]byte(pageURL)) | |||
| 93 | + | return hash[:] | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // ParseURL is a convenience wrapper around url.Parse | |||
| 97 | + | func ParseURL(rawURL string) (*url.URL, error) { | |||
| 98 | + | return url.Parse(rawURL) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // FaviconFinishSafe mirrors gotk4's FaviconFinish but guards against NULL returns. | |||
| 102 | + | func FaviconFinishSafe(database *webkit.FaviconDatabase, result gio.AsyncResulter) (*gdk.Texture, error) { | |||
| 103 | + | if database == nil || result == nil { | |||
| 104 | + | return nil, fmt.Errorf("favicon finish: invalid arguments") | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | cDatabase := (*C.WebKitFaviconDatabase)(unsafe.Pointer(glib.BaseObject(database).Native())) | |||
| 108 | + | cResult := (*C.GAsyncResult)(unsafe.Pointer(glib.BaseObject(result).Native())) | |||
| 109 | + | ||||
| 110 | + | var cErr *C.GError | |||
| 111 | + | cTexture := C.webkit_favicon_database_get_favicon_finish(cDatabase, cResult, &cErr) | |||
| 112 | + | runtime.KeepAlive(database) | |||
| 113 | + | runtime.KeepAlive(result) | |||
| 114 | + | ||||
| 115 | + | if cErr != nil { | |||
| 116 | + | err := gerror.Take(unsafe.Pointer(cErr)) | |||
| 117 | + | return nil, err | |||
| 118 | + | } | |||
| 119 | + | if cTexture == nil { | |||
| 120 | + | return nil, nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | object := coreglib.AssumeOwnership(unsafe.Pointer(cTexture)) | |||
| 124 | + | casted := object.WalkCast(func(obj coreglib.Objector) bool { | |||
| 125 | + | _, ok := obj.(gdk.Texturer) | |||
| 126 | + | return ok | |||
| 127 | + | }) | |||
| 128 | + | ||||
| 129 | + | texturer, ok := casted.(gdk.Texturer) | |||
| 130 | + | if !ok { | |||
| 131 | + | return nil, fmt.Errorf("favicon finish: unexpected type %T", casted) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | texture := gdk.BaseTexture(texturer) | |||
| 135 | + | if texture == nil { | |||
| 136 | + | return nil, fmt.Errorf("favicon finish: texture unavailable") | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | return texture, nil | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // TextureFromImageBytes creates a texture from raw image bytes. | |||
| 143 | + | // Supported formats depend on the available GdkPixbuf loaders (PNG, SVG, etc.). | |||
| 144 | + | func TextureFromImageBytes(data []byte) (*gdk.Texture, error) { | |||
| 145 | + | if len(data) == 0 { | |||
| 146 | + | return nil, fmt.Errorf("image data is empty") | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // Convert []byte to glib.Bytes for gio API | |||
| 150 | + | glibBytes := glib.NewBytes(data) | |||
| 151 | + | stream := gio.NewMemoryInputStreamFromBytes(glibBytes) | |||
| 152 | + | pixbuf, err := gdkpixbuf.NewPixbufFromStream(context.Background(), stream) | |||
| 153 | + | if err != nil { | |||
| 154 | + | return nil, fmt.Errorf("load pixbuf: %w", err) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | texture := gdk.NewTextureForPixbuf(pixbuf) | |||
| 158 | + | if texture == nil { | |||
| 159 | + | return nil, fmt.Errorf("create texture from pixbuf failed") | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | return texture, nil | |||
| 163 | + | } | |||
M pkg/webkit/tls_handler.go +2 -2
| @@ -18,8 +18,8 @@ import ( | |||
| 18 | 18 | ||
| 19 | 19 | // TLSCertificateInfo holds information about a TLS certificate for display | |
| 20 | 20 | type TLSCertificateInfo struct { | |
| 21 | - | Subject string | |
| 22 | - | Issuer string | |
| 21 | + | Subject string | |
| 22 | + | Issuer string | |
| 23 | 23 | NotBefore string | |
| 24 | 24 | NotAfter string | |
| 25 | 25 | IsExpired bool | |
| @@ -18,8 +18,8 @@ import ( | |||||
| 18 | 18 | ||||
| 19 | // TLSCertificateInfo holds information about a TLS certificate for display | 19 | // TLSCertificateInfo holds information about a TLS certificate for display | ||
| 20 | type TLSCertificateInfo struct { | 20 | type TLSCertificateInfo struct { | ||
| 21 | - | Subject string | 21 | + | Subject string |
| 22 | - | Issuer string | 22 | + | Issuer string |
| 23 | NotBefore string | 23 | NotBefore string | ||
| 24 | NotAfter string | 24 | NotAfter string | ||
| 25 | IsExpired bool | 25 | IsExpired bool | ||
M pkg/webkit/webview.go +37 -23
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "sync/atomic" | |
| 10 | 10 | ||
| 11 | 11 | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | |
| 12 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |
| 12 | 13 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| @@ -76,6 +77,16 @@ func NewWebView(cfg *Config) (*WebView, error) { | |||
| 76 | 77 | return nil, ErrWebViewNotInitialized | |
| 77 | 78 | } | |
| 78 | 79 | ||
| 80 | + | // Set background color based on user's theme preference to prevent white/black flash | |
| 81 | + | // This must be done before any content loads | |
| 82 | + | var bg gdk.RGBA | |
| 83 | + | if PrefersDarkTheme() { | |
| 84 | + | bg = gdk.NewRGBA(0.11, 0.11, 0.11, 1.0) // #1c1c1c - dark background | |
| 85 | + | } else { | |
| 86 | + | bg = gdk.NewRGBA(0.96, 0.96, 0.96, 1.0) // #f5f5f5 - light background | |
| 87 | + | } | |
| 88 | + | wkView.SetBackgroundColor(&bg) | |
| 89 | + | ||
| 79 | 90 | // Verify the WebView is using the persistent session | |
| 80 | 91 | viewSession := wkView.NetworkSession() | |
| 81 | 92 | if viewSession == nil { | |
| @@ -715,6 +726,29 @@ func (w *WebView) Window() *Window { | |||
| 715 | 726 | return w.window | |
| 716 | 727 | } | |
| 717 | 728 | ||
| 729 | + | // GetFaviconDatabase returns the WebKit FaviconDatabase for this WebView | |
| 730 | + | // Returns nil if the favicon database is not available | |
| 731 | + | func (w *WebView) GetFaviconDatabase() *webkit.FaviconDatabase { | |
| 732 | + | w.mu.RLock() | |
| 733 | + | defer w.mu.RUnlock() | |
| 734 | + | ||
| 735 | + | if w.destroyed || w.view == nil { | |
| 736 | + | return nil | |
| 737 | + | } | |
| 738 | + | ||
| 739 | + | session := w.view.NetworkSession() | |
| 740 | + | if session == nil { | |
| 741 | + | return nil | |
| 742 | + | } | |
| 743 | + | ||
| 744 | + | dataManager := session.WebsiteDataManager() | |
| 745 | + | if dataManager == nil { | |
| 746 | + | return nil | |
| 747 | + | } | |
| 748 | + | ||
| 749 | + | return dataManager.FaviconDatabase() | |
| 750 | + | } | |
| 751 | + | ||
| 718 | 752 | // UpdateContentFilters updates the content filtering rules | |
| 719 | 753 | func (w *WebView) UpdateContentFilters(rules string) error { | |
| 720 | 754 | w.mu.RLock() | |
| @@ -773,7 +807,9 @@ func (w *WebView) RegisterPaneModeHandler(handler func(action string) bool, isAc | |||
| 773 | 807 | w.isPaneModeActive = isActiveChecker | |
| 774 | 808 | } | |
| 775 | 809 | ||
| 776 | - | // setupFaviconHandlers connects to the FaviconDatabase signals | |
| 810 | + | // setupFaviconHandlers enables favicons for this WebView | |
| 811 | + | // Note: The actual favicon-changed handler is registered ONCE at the FaviconService level | |
| 812 | + | // to avoid duplicate handlers when multiple WebViews exist | |
| 777 | 813 | func (w *WebView) setupFaviconHandlers() { | |
| 778 | 814 | // Get the NetworkSession from the WebView | |
| 779 | 815 | session := w.view.NetworkSession() | |
| @@ -795,28 +831,6 @@ func (w *WebView) setupFaviconHandlers() { | |||
| 795 | 831 | log.Printf("[webkit] Enabled favicons for WebView ID %d", w.id) | |
| 796 | 832 | } | |
| 797 | 833 | ||
| 798 | - | // Get the FaviconDatabase | |
| 799 | - | faviconDB := dataManager.FaviconDatabase() | |
| 800 | - | if faviconDB == nil { | |
| 801 | - | log.Printf("[webkit] Warning: No FaviconDatabase available") | |
| 802 | - | return | |
| 803 | - | } | |
| 804 | - | ||
| 805 | - | // Connect to the favicon-changed signal | |
| 806 | - | faviconDB.ConnectFaviconChanged(func(pageURI, faviconURI string) { | |
| 807 | - | log.Printf("[favicon] Favicon changed for %s: %s", pageURI, faviconURI) | |
| 808 | - | ||
| 809 | - | // Call the URI handler if registered | |
| 810 | - | w.mu.RLock() | |
| 811 | - | handler := w.onFaviconURIChanged | |
| 812 | - | w.mu.RUnlock() | |
| 813 | - | ||
| 814 | - | if handler != nil { | |
| 815 | - | handler(pageURI, faviconURI) | |
| 816 | - | } | |
| 817 | - | }) | |
| 818 | - | ||
| 819 | - | log.Printf("[webkit] Connected to FaviconDatabase for WebView ID %d", w.id) | |
| 820 | 834 | } | |
| 821 | 835 | ||
| 822 | 836 | // GtkWebView returns the underlying gotk4 WebView for advanced operations | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "sync/atomic" | 9 | "sync/atomic" | ||
| 10 | 10 | ||||
| 11 | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | 11 | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | ||
| 12 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |||
| 12 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | 13 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | ||
| 13 | ) | 14 | ) | ||
| 14 | 15 | ||||
| @@ -76,6 +77,16 @@ func NewWebView(cfg *Config) (*WebView, error) { | |||||
| 76 | return nil, ErrWebViewNotInitialized | 77 | return nil, ErrWebViewNotInitialized | ||
| 77 | } | 78 | } | ||
| 78 | 79 | ||||
| 80 | + | // Set background color based on user's theme preference to prevent white/black flash | |||
| 81 | + | // This must be done before any content loads | |||
| 82 | + | var bg gdk.RGBA | |||
| 83 | + | if PrefersDarkTheme() { | |||
| 84 | + | bg = gdk.NewRGBA(0.11, 0.11, 0.11, 1.0) // #1c1c1c - dark background | |||
| 85 | + | } else { | |||
| 86 | + | bg = gdk.NewRGBA(0.96, 0.96, 0.96, 1.0) // #f5f5f5 - light background | |||
| 87 | + | } | |||
| 88 | + | wkView.SetBackgroundColor(&bg) | |||
| 89 | + | ||||
| 79 | // Verify the WebView is using the persistent session | 90 | // Verify the WebView is using the persistent session | ||
| 80 | viewSession := wkView.NetworkSession() | 91 | viewSession := wkView.NetworkSession() | ||
| 81 | if viewSession == nil { | 92 | if viewSession == nil { | ||
| @@ -715,6 +726,29 @@ func (w *WebView) Window() *Window { | |||||
| 715 | return w.window | 726 | return w.window | ||
| 716 | } | 727 | } | ||
| 717 | 728 | ||||
| 729 | + | // GetFaviconDatabase returns the WebKit FaviconDatabase for this WebView | |||
| 730 | + | // Returns nil if the favicon database is not available | |||
| 731 | + | func (w *WebView) GetFaviconDatabase() *webkit.FaviconDatabase { | |||
| 732 | + | w.mu.RLock() | |||
| 733 | + | defer w.mu.RUnlock() | |||
| 734 | + | ||||
| 735 | + | if w.destroyed || w.view == nil { | |||
| 736 | + | return nil | |||
| 737 | + | } | |||
| 738 | + | ||||
| 739 | + | session := w.view.NetworkSession() | |||
| 740 | + | if session == nil { | |||
| 741 | + | return nil | |||
| 742 | + | } | |||
| 743 | + | ||||
| 744 | + | dataManager := session.WebsiteDataManager() | |||
| 745 | + | if dataManager == nil { | |||
| 746 | + | return nil | |||
| 747 | + | } | |||
| 748 | + | ||||
| 749 | + | return dataManager.FaviconDatabase() | |||
| 750 | + | } | |||
| 751 | + | ||||
| 718 | // UpdateContentFilters updates the content filtering rules | 752 | // UpdateContentFilters updates the content filtering rules | ||
| 719 | func (w *WebView) UpdateContentFilters(rules string) error { | 753 | func (w *WebView) UpdateContentFilters(rules string) error { | ||
| 720 | w.mu.RLock() | 754 | w.mu.RLock() | ||
| @@ -773,7 +807,9 @@ func (w *WebView) RegisterPaneModeHandler(handler func(action string) bool, isAc | |||||
| 773 | w.isPaneModeActive = isActiveChecker | 807 | w.isPaneModeActive = isActiveChecker | ||
| 774 | } | 808 | } | ||
| 775 | 809 | ||||
| 776 | - | // setupFaviconHandlers connects to the FaviconDatabase signals | 810 | + | // setupFaviconHandlers enables favicons for this WebView |
| 811 | + | // Note: The actual favicon-changed handler is registered ONCE at the FaviconService level | |||
| 812 | + | // to avoid duplicate handlers when multiple WebViews exist | |||
| 777 | func (w *WebView) setupFaviconHandlers() { | 813 | func (w *WebView) setupFaviconHandlers() { | ||
| 778 | // Get the NetworkSession from the WebView | 814 | // Get the NetworkSession from the WebView | ||
| 779 | session := w.view.NetworkSession() | 815 | session := w.view.NetworkSession() | ||
| @@ -795,28 +831,6 @@ func (w *WebView) setupFaviconHandlers() { | |||||
| 795 | log.Printf("[webkit] Enabled favicons for WebView ID %d", w.id) | 831 | log.Printf("[webkit] Enabled favicons for WebView ID %d", w.id) | ||
| 796 | } | 832 | } | ||
| 797 | 833 | ||||
| 798 | - | // Get the FaviconDatabase | |||
| 799 | - | faviconDB := dataManager.FaviconDatabase() | |||
| 800 | - | if faviconDB == nil { | |||
| 801 | - | log.Printf("[webkit] Warning: No FaviconDatabase available") | |||
| 802 | - | return | |||
| 803 | - | } | |||
| 804 | - | ||||
| 805 | - | // Connect to the favicon-changed signal | |||
| 806 | - | faviconDB.ConnectFaviconChanged(func(pageURI, faviconURI string) { | |||
| 807 | - | log.Printf("[favicon] Favicon changed for %s: %s", pageURI, faviconURI) | |||
| 808 | - | ||||
| 809 | - | // Call the URI handler if registered | |||
| 810 | - | w.mu.RLock() | |||
| 811 | - | handler := w.onFaviconURIChanged | |||
| 812 | - | w.mu.RUnlock() | |||
| 813 | - | ||||
| 814 | - | if handler != nil { | |||
| 815 | - | handler(pageURI, faviconURI) | |||
| 816 | - | } | |||
| 817 | - | }) | |||
| 818 | - | ||||
| 819 | - | log.Printf("[webkit] Connected to FaviconDatabase for WebView ID %d", w.id) | |||
| 820 | } | 834 | } | ||
| 821 | 835 | ||||
| 822 | // GtkWebView returns the underlying gotk4 WebView for advanced operations | 836 | // GtkWebView returns the underlying gotk4 WebView for advanced operations | ||
M pkg/webkit/webview_related.go +2 -1
| @@ -25,6 +25,7 @@ import ( | |||
| 25 | 25 | ||
| 26 | 26 | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | |
| 27 | 27 | coreglib "github.com/diamondburned/gotk4/pkg/core/glib" | |
| 28 | + | "github.com/diamondburned/gotk4/pkg/glib/v2" | |
| 28 | 29 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 29 | 30 | ) | |
| 30 | 31 | ||
| @@ -41,7 +42,7 @@ func NewBareRelatedWebView(parentView *webkit.WebView) *webkit.WebView { | |||
| 41 | 42 | } | |
| 42 | 43 | ||
| 43 | 44 | // Get the native C pointer from the gotk4 WebView | |
| 44 | - | parentObj := coreglib.InternObject(parentView) | |
| 45 | + | parentObj := glib.BaseObject(parentView) | |
| 45 | 46 | if parentObj == nil { | |
| 46 | 47 | log.Printf("[webkit] NewBareRelatedWebView: failed to get parent object, creating unrelated WebView") | |
| 47 | 48 | return webkit.NewWebView() | |
| @@ -25,6 +25,7 @@ import ( | |||||
| 25 | 25 | ||||
| 26 | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | 26 | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | ||
| 27 | coreglib "github.com/diamondburned/gotk4/pkg/core/glib" | 27 | coreglib "github.com/diamondburned/gotk4/pkg/core/glib" | ||
| 28 | + | "github.com/diamondburned/gotk4/pkg/glib/v2" | |||
| 28 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | 29 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | ||
| 29 | ) | 30 | ) | ||
| 30 | 31 | ||||
| @@ -41,7 +42,7 @@ func NewBareRelatedWebView(parentView *webkit.WebView) *webkit.WebView { | |||||
| 41 | } | 42 | } | ||
| 42 | 43 | ||||
| 43 | // Get the native C pointer from the gotk4 WebView | 44 | // Get the native C pointer from the gotk4 WebView | ||
| 44 | - | parentObj := coreglib.InternObject(parentView) | 45 | + | parentObj := glib.BaseObject(parentView) |
| 45 | if parentObj == nil { | 46 | if parentObj == nil { | ||
| 46 | log.Printf("[webkit] NewBareRelatedWebView: failed to get parent object, creating unrelated WebView") | 47 | log.Printf("[webkit] NewBareRelatedWebView: failed to get parent object, creating unrelated WebView") | ||
| 47 | return webkit.NewWebView() | 48 | return webkit.NewWebView() | ||
M pkg/webkit/window.go +13 -0
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package webkit | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "log" | |
| 5 | + | ||
| 4 | 6 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 5 | 7 | ) | |
| 6 | 8 | ||
| @@ -21,6 +23,17 @@ func NewWindow(title string) (*Window, error) { | |||
| 21 | 23 | win.SetTitle(title) | |
| 22 | 24 | win.SetDefaultSize(1024, 768) | |
| 23 | 25 | ||
| 26 | + | // Connect close-request signal to handle Cmd+Q and window close button | |
| 27 | + | // Returning true prevents the default close behavior and allows graceful shutdown | |
| 28 | + | win.ConnectCloseRequest(func() bool { | |
| 29 | + | log.Printf("[window] Close request received (Cmd+Q or close button) - initiating graceful shutdown") | |
| 30 | + | // Quit the main loop gracefully, which will trigger cleanup | |
| 31 | + | QuitMainLoop() | |
| 32 | + | // Return true to prevent GTK from destroying the window immediately | |
| 33 | + | // The cleanup process will handle window destruction | |
| 34 | + | return true | |
| 35 | + | }) | |
| 36 | + | ||
| 24 | 37 | return &Window{win: win}, nil | |
| 25 | 38 | } | |
| 26 | 39 | ||
| @@ -1,6 +1,8 @@ | |||||
| 1 | package webkit | 1 | package webkit | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "log" | |||
| 5 | + | ||||
| 4 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | 6 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | ||
| 5 | ) | 7 | ) | ||
| 6 | 8 | ||||
| @@ -21,6 +23,17 @@ func NewWindow(title string) (*Window, error) { | |||||
| 21 | win.SetTitle(title) | 23 | win.SetTitle(title) | ||
| 22 | win.SetDefaultSize(1024, 768) | 24 | win.SetDefaultSize(1024, 768) | ||
| 23 | 25 | ||||
| 26 | + | // Connect close-request signal to handle Cmd+Q and window close button | |||
| 27 | + | // Returning true prevents the default close behavior and allows graceful shutdown | |||
| 28 | + | win.ConnectCloseRequest(func() bool { | |||
| 29 | + | log.Printf("[window] Close request received (Cmd+Q or close button) - initiating graceful shutdown") | |||
| 30 | + | // Quit the main loop gracefully, which will trigger cleanup | |||
| 31 | + | QuitMainLoop() | |||
| 32 | + | // Return true to prevent GTK from destroying the window immediately | |||
| 33 | + | // The cleanup process will handle window destruction | |||
| 34 | + | return true | |||
| 35 | + | }) | |||
| 36 | + | ||||
| 24 | return &Window{win: win}, nil | 37 | return &Window{win: win}, nil | ||
| 25 | } | 38 | } | ||
| 26 | 39 | ||||