fix(dmenu): display URLs and add PNG favicon support for rofi/fuzzel
Fixes two dmenu selection issues: - Display URLs (scheme-stripped) instead of titles, ensuring rofi/fuzzel selection directly returns the usable URL - Add PNG favicon format support (PNG/SVG only) by introducing SanitizeDomainForPNG and PNG path/write methods in favicon service Simplified parseSelection logic since display now contains the URL directly.
- commit
- d6a17f6240e91f1cfc911421e5f8d451571b0499
- author
- bnema · · 2025-12-18 21:42 UTC
- parent
- 3011e5d
- signature
- unsigned
6 files · +130 -46
M internal/cli/cmd/dmenu.go +27 -41
| @@ -77,8 +77,9 @@ func getDmenuConfig() config.DmenuConfig { | |||
| 77 | 77 | return cfg | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | - | // getFaviconPath returns the path to a cached favicon for the given URL. | |
| 81 | - | // Returns empty string if the favicon doesn't exist in cache. | |
| 80 | + | // getFaviconPath returns the path to a cached PNG favicon for the given URL. | |
| 81 | + | // Returns empty string if the PNG favicon doesn't exist in cache. | |
| 82 | + | // PNG format is required by rofi/fuzzel launchers. | |
| 82 | 83 | func getFaviconPath(rawURL string) string { | |
| 83 | 84 | cacheDir, err := config.GetFaviconCacheDir() | |
| 84 | 85 | if err != nil { | |
| @@ -88,7 +89,8 @@ func getFaviconPath(rawURL string) string { | |||
| 88 | 89 | if domain == "" { | |
| 89 | 90 | return "" | |
| 90 | 91 | } | |
| 91 | - | filename := domainurl.SanitizeDomainForFilename(domain) | |
| 92 | + | // Use PNG format for fuzzel/rofi compatibility | |
| 93 | + | filename := domainurl.SanitizeDomainForPNG(domain) | |
| 92 | 94 | path := filepath.Join(cacheDir, filename) | |
| 93 | 95 | if _, err := os.Stat(path); err == nil { | |
| 94 | 96 | return path | |
| @@ -108,34 +110,27 @@ func runDmenuPipe() error { | |||
| 108 | 110 | } | |
| 109 | 111 | ||
| 110 | 112 | // Output in rofi/fuzzel compatible format | |
| 113 | + | // Display URL (stripped of scheme) so selection directly returns usable URL | |
| 111 | 114 | for _, entry := range entries { | |
| 112 | - | title := entry.Title | |
| 113 | - | if title == "" { | |
| 114 | - | title = entry.URL | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | // Truncate long titles | |
| 118 | - | if len(title) > 80 { | |
| 119 | - | title = title[:77] + "..." | |
| 115 | + | // Strip scheme from URL for cleaner display | |
| 116 | + | display := entry.URL | |
| 117 | + | display = strings.TrimPrefix(display, "https://") | |
| 118 | + | display = strings.TrimPrefix(display, "http://") | |
| 119 | + | ||
| 120 | + | // Truncate long URLs | |
| 121 | + | const maxDisplayLen = 100 | |
| 122 | + | if len(display) > maxDisplayLen { | |
| 123 | + | display = display[:maxDisplayLen-3] + "..." | |
| 120 | 124 | } | |
| 121 | 125 | ||
| 122 | 126 | // Check if we have a favicon | |
| 123 | 127 | faviconPath := getFaviconPath(entry.URL) | |
| 124 | 128 | ||
| 125 | - | // Build display text | |
| 126 | - | var displayText string | |
| 127 | - | if faviconPath != "" { | |
| 128 | - | displayText = title | |
| 129 | - | } else { | |
| 130 | - | displayText = cfg.HistoryPrefix + " " + title | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | // Format: "DisplayText\0icon\x1f/path\x1finfo\x1fURL\n" | |
| 134 | - | // Using info field to store URL for selection | |
| 129 | + | // Format: "URL\0icon\x1f/path\n" (simple, selection returns URL directly) | |
| 135 | 130 | if faviconPath != "" { | |
| 136 | - | fmt.Printf("%s\x00icon\x1f%s\x1finfo\x1f%s\n", displayText, faviconPath, entry.URL) | |
| 131 | + | fmt.Printf("%s\x00icon\x1f%s\n", display, faviconPath) | |
| 137 | 132 | } else { | |
| 138 | - | fmt.Printf("%s\x00info\x1f%s\n", displayText, entry.URL) | |
| 133 | + | fmt.Println(display) | |
| 139 | 134 | } | |
| 140 | 135 | } | |
| 141 | 136 | ||
| @@ -164,32 +159,23 @@ func runDmenuSelect() error { | |||
| 164 | 159 | } | |
| 165 | 160 | ||
| 166 | 161 | // parseSelection extracts the URL from a dmenu selection. | |
| 162 | + | // Selection is the URL (stripped of scheme) as displayed by runDmenuPipe. | |
| 167 | 163 | func parseSelection(selection string) string { | |
| 168 | 164 | selection = strings.TrimSpace(selection) | |
| 169 | 165 | ||
| 170 | - | // Extract info field if present (format: "text\0...info\x1fURL") | |
| 171 | - | if infoIndex := strings.Index(selection, "info\x1f"); infoIndex > 0 { | |
| 172 | - | info := selection[infoIndex+5:] // Skip "info\x1f" | |
| 173 | - | // Info might have more fields after, but URL should be first | |
| 174 | - | if endIndex := strings.Index(info, "\x1f"); endIndex > 0 { | |
| 175 | - | return strings.TrimSpace(info[:endIndex]) | |
| 176 | - | } | |
| 177 | - | return strings.TrimSpace(info) | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | // Strip metadata if present (format: "text\0...") | |
| 166 | + | // Strip metadata if present (format: "url\0icon\x1f...") | |
| 181 | 167 | if nullIndex := strings.Index(selection, "\x00"); nullIndex > 0 { | |
| 182 | 168 | selection = selection[:nullIndex] | |
| 183 | 169 | } | |
| 184 | 170 | ||
| 185 | - | // Legacy: strip emoji prefix if present | |
| 186 | - | selection = strings.TrimPrefix(selection, "🕒 ") | |
| 187 | - | selection = strings.TrimPrefix(selection, "🔍 ") | |
| 188 | - | selection = strings.TrimPrefix(selection, "🌐 ") | |
| 171 | + | selection = strings.TrimSpace(selection) | |
| 172 | + | if selection == "" { | |
| 173 | + | return "" | |
| 174 | + | } | |
| 189 | 175 | ||
| 190 | - | // If it looks like a URL, return it | |
| 191 | - | if strings.Contains(selection, "://") || strings.Contains(selection, ".") { | |
| 192 | - | return selection | |
| 176 | + | // Add https:// scheme if not present (display was stripped of scheme) | |
| 177 | + | if !strings.Contains(selection, "://") { | |
| 178 | + | selection = "https://" + selection | |
| 193 | 179 | } | |
| 194 | 180 | ||
| 195 | 181 | return selection | |
| @@ -77,8 +77,9 @@ func getDmenuConfig() config.DmenuConfig { | |||||
| 77 | return cfg | 77 | return cfg | ||
| 78 | } | 78 | } | ||
| 79 | 79 | ||||
| 80 | - | // getFaviconPath returns the path to a cached favicon for the given URL. | 80 | + | // getFaviconPath returns the path to a cached PNG favicon for the given URL. |
| 81 | - | // Returns empty string if the favicon doesn't exist in cache. | 81 | + | // Returns empty string if the PNG favicon doesn't exist in cache. |
| 82 | + | // PNG format is required by rofi/fuzzel launchers. | |||
| 82 | func getFaviconPath(rawURL string) string { | 83 | func getFaviconPath(rawURL string) string { | ||
| 83 | cacheDir, err := config.GetFaviconCacheDir() | 84 | cacheDir, err := config.GetFaviconCacheDir() | ||
| 84 | if err != nil { | 85 | if err != nil { | ||
| @@ -88,7 +89,8 @@ func getFaviconPath(rawURL string) string { | |||||
| 88 | if domain == "" { | 89 | if domain == "" { | ||
| 89 | return "" | 90 | return "" | ||
| 90 | } | 91 | } | ||
| 91 | - | filename := domainurl.SanitizeDomainForFilename(domain) | 92 | + | // Use PNG format for fuzzel/rofi compatibility |
| 93 | + | filename := domainurl.SanitizeDomainForPNG(domain) | |||
| 92 | path := filepath.Join(cacheDir, filename) | 94 | path := filepath.Join(cacheDir, filename) | ||
| 93 | if _, err := os.Stat(path); err == nil { | 95 | if _, err := os.Stat(path); err == nil { | ||
| 94 | return path | 96 | return path | ||
| @@ -108,34 +110,27 @@ func runDmenuPipe() error { | |||||
| 108 | } | 110 | } | ||
| 109 | 111 | ||||
| 110 | // Output in rofi/fuzzel compatible format | 112 | // Output in rofi/fuzzel compatible format | ||
| 113 | + | // Display URL (stripped of scheme) so selection directly returns usable URL | |||
| 111 | for _, entry := range entries { | 114 | for _, entry := range entries { | ||
| 112 | - | title := entry.Title | 115 | + | // Strip scheme from URL for cleaner display |
| 113 | - | if title == "" { | 116 | + | display := entry.URL |
| 114 | - | title = entry.URL | 117 | + | display = strings.TrimPrefix(display, "https://") |
| 115 | - | } | 118 | + | display = strings.TrimPrefix(display, "http://") |
| 116 | - | 119 | + | ||
| 117 | - | // Truncate long titles | 120 | + | // Truncate long URLs |
| 118 | - | if len(title) > 80 { | 121 | + | const maxDisplayLen = 100 |
| 119 | - | title = title[:77] + "..." | 122 | + | if len(display) > maxDisplayLen { |
| 123 | + | display = display[:maxDisplayLen-3] + "..." | |||
| 120 | } | 124 | } | ||
| 121 | 125 | ||||
| 122 | // Check if we have a favicon | 126 | // Check if we have a favicon | ||
| 123 | faviconPath := getFaviconPath(entry.URL) | 127 | faviconPath := getFaviconPath(entry.URL) | ||
| 124 | 128 | ||||
| 125 | - | // Build display text | 129 | + | // Format: "URL\0icon\x1f/path\n" (simple, selection returns URL directly) |
| 126 | - | var displayText string | |||
| 127 | - | if faviconPath != "" { | |||
| 128 | - | displayText = title | |||
| 129 | - | } else { | |||
| 130 | - | displayText = cfg.HistoryPrefix + " " + title | |||
| 131 | - | } | |||
| 132 | - | ||||
| 133 | - | // Format: "DisplayText\0icon\x1f/path\x1finfo\x1fURL\n" | |||
| 134 | - | // Using info field to store URL for selection | |||
| 135 | if faviconPath != "" { | 130 | if faviconPath != "" { | ||
| 136 | - | fmt.Printf("%s\x00icon\x1f%s\x1finfo\x1f%s\n", displayText, faviconPath, entry.URL) | 131 | + | fmt.Printf("%s\x00icon\x1f%s\n", display, faviconPath) |
| 137 | } else { | 132 | } else { | ||
| 138 | - | fmt.Printf("%s\x00info\x1f%s\n", displayText, entry.URL) | 133 | + | fmt.Println(display) |
| 139 | } | 134 | } | ||
| 140 | } | 135 | } | ||
| 141 | 136 | ||||
| @@ -164,32 +159,23 @@ func runDmenuSelect() error { | |||||
| 164 | } | 159 | } | ||
| 165 | 160 | ||||
| 166 | // parseSelection extracts the URL from a dmenu selection. | 161 | // parseSelection extracts the URL from a dmenu selection. | ||
| 162 | + | // Selection is the URL (stripped of scheme) as displayed by runDmenuPipe. | |||
| 167 | func parseSelection(selection string) string { | 163 | func parseSelection(selection string) string { | ||
| 168 | selection = strings.TrimSpace(selection) | 164 | selection = strings.TrimSpace(selection) | ||
| 169 | 165 | ||||
| 170 | - | // Extract info field if present (format: "text\0...info\x1fURL") | 166 | + | // Strip metadata if present (format: "url\0icon\x1f...") |
| 171 | - | if infoIndex := strings.Index(selection, "info\x1f"); infoIndex > 0 { | |||
| 172 | - | info := selection[infoIndex+5:] // Skip "info\x1f" | |||
| 173 | - | // Info might have more fields after, but URL should be first | |||
| 174 | - | if endIndex := strings.Index(info, "\x1f"); endIndex > 0 { | |||
| 175 | - | return strings.TrimSpace(info[:endIndex]) | |||
| 176 | - | } | |||
| 177 | - | return strings.TrimSpace(info) | |||
| 178 | - | } | |||
| 179 | - | ||||
| 180 | - | // Strip metadata if present (format: "text\0...") | |||
| 181 | if nullIndex := strings.Index(selection, "\x00"); nullIndex > 0 { | 167 | if nullIndex := strings.Index(selection, "\x00"); nullIndex > 0 { | ||
| 182 | selection = selection[:nullIndex] | 168 | selection = selection[:nullIndex] | ||
| 183 | } | 169 | } | ||
| 184 | 170 | ||||
| 185 | - | // Legacy: strip emoji prefix if present | 171 | + | selection = strings.TrimSpace(selection) |
| 186 | - | selection = strings.TrimPrefix(selection, "🕒 ") | 172 | + | if selection == "" { |
| 187 | - | selection = strings.TrimPrefix(selection, "🔍 ") | 173 | + | return "" |
| 188 | - | selection = strings.TrimPrefix(selection, "🌐 ") | 174 | + | } |
| 189 | 175 | ||||
| 190 | - | // If it looks like a URL, return it | 176 | + | // Add https:// scheme if not present (display was stripped of scheme) |
| 191 | - | if strings.Contains(selection, "://") || strings.Contains(selection, ".") { | 177 | + | if !strings.Contains(selection, "://") { |
| 192 | - | return selection | 178 | + | selection = "https://" + selection |
| 193 | } | 179 | } | ||
| 194 | 180 | ||||
| 195 | return selection | 181 | return selection | ||
M internal/domain/service/favicon.go +12 -2
| @@ -17,10 +17,20 @@ type FaviconService interface { | |||
| 17 | 17 | // Store saves favicon bytes for a domain to cache. | |
| 18 | 18 | Store(domain string, data []byte) error | |
| 19 | 19 | ||
| 20 | - | // DiskPath returns the filesystem path where a domain's favicon is cached. | |
| 21 | - | // Used by CLI tools (rofi/fuzzel) that need file paths for icon display. | |
| 20 | + | // DiskPath returns the filesystem path where a domain's favicon is cached (ICO). | |
| 22 | 21 | DiskPath(domain string) string | |
| 23 | 22 | ||
| 23 | + | // DiskPathPNG returns the filesystem path for PNG favicon. | |
| 24 | + | // PNG format is required by CLI tools like rofi/fuzzel. | |
| 25 | + | DiskPathPNG(domain string) string | |
| 26 | + | ||
| 27 | + | // HasPNGOnDisk checks if a PNG favicon exists on disk for the domain. | |
| 28 | + | HasPNGOnDisk(domain string) bool | |
| 29 | + | ||
| 30 | + | // WritePNG writes raw PNG data to disk for a domain. | |
| 31 | + | // Used by UI layer to export WebKit textures for CLI tools. | |
| 32 | + | WritePNG(domain string, pngData []byte) | |
| 33 | + | ||
| 24 | 34 | // Close shuts down background workers and releases resources. | |
| 25 | 35 | Close() | |
| 26 | 36 | } | |
| @@ -17,10 +17,20 @@ type FaviconService interface { | |||||
| 17 | // Store saves favicon bytes for a domain to cache. | 17 | // Store saves favicon bytes for a domain to cache. | ||
| 18 | Store(domain string, data []byte) error | 18 | Store(domain string, data []byte) error | ||
| 19 | 19 | ||||
| 20 | - | // DiskPath returns the filesystem path where a domain's favicon is cached. | 20 | + | // DiskPath returns the filesystem path where a domain's favicon is cached (ICO). |
| 21 | - | // Used by CLI tools (rofi/fuzzel) that need file paths for icon display. | |||
| 22 | DiskPath(domain string) string | 21 | DiskPath(domain string) string | ||
| 23 | 22 | ||||
| 23 | + | // DiskPathPNG returns the filesystem path for PNG favicon. | |||
| 24 | + | // PNG format is required by CLI tools like rofi/fuzzel. | |||
| 25 | + | DiskPathPNG(domain string) string | |||
| 26 | + | ||||
| 27 | + | // HasPNGOnDisk checks if a PNG favicon exists on disk for the domain. | |||
| 28 | + | HasPNGOnDisk(domain string) bool | |||
| 29 | + | ||||
| 30 | + | // WritePNG writes raw PNG data to disk for a domain. | |||
| 31 | + | // Used by UI layer to export WebKit textures for CLI tools. | |||
| 32 | + | WritePNG(domain string, pngData []byte) | |||
| 33 | + | ||||
| 24 | // Close shuts down background workers and releases resources. | 34 | // Close shuts down background workers and releases resources. | ||
| 25 | Close() | 35 | Close() | ||
| 26 | } | 36 | } | ||
M internal/domain/url/normalize.go +12 -1
| @@ -62,6 +62,17 @@ func ExtractDomain(rawURL string) string { | |||
| 62 | 62 | // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension. | |
| 63 | 63 | // Replaces unsafe filesystem characters with underscores. | |
| 64 | 64 | func SanitizeDomainForFilename(domain string) string { | |
| 65 | + | return sanitizeDomain(domain) + ".ico" | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // SanitizeDomainForPNG converts a domain to a safe filename with .png extension. | |
| 69 | + | // Used for favicon export for tools like rofi/fuzzel that require PNG format. | |
| 70 | + | func SanitizeDomainForPNG(domain string) string { | |
| 71 | + | return sanitizeDomain(domain) + ".png" | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // sanitizeDomain replaces unsafe filesystem characters with underscores. | |
| 75 | + | func sanitizeDomain(domain string) string { | |
| 65 | 76 | replacer := strings.NewReplacer( | |
| 66 | 77 | ":", "_", | |
| 67 | 78 | "/", "_", | |
| @@ -73,5 +84,5 @@ func SanitizeDomainForFilename(domain string) string { | |||
| 73 | 84 | ">", "_", | |
| 74 | 85 | "|", "_", | |
| 75 | 86 | ) | |
| 76 | - | return replacer.Replace(domain) + ".ico" | |
| 87 | + | return replacer.Replace(domain) | |
| 77 | 88 | } | |
| @@ -62,6 +62,17 @@ func ExtractDomain(rawURL string) string { | |||||
| 62 | // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension. | 62 | // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension. | ||
| 63 | // Replaces unsafe filesystem characters with underscores. | 63 | // Replaces unsafe filesystem characters with underscores. | ||
| 64 | func SanitizeDomainForFilename(domain string) string { | 64 | func SanitizeDomainForFilename(domain string) string { | ||
| 65 | + | return sanitizeDomain(domain) + ".ico" | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // SanitizeDomainForPNG converts a domain to a safe filename with .png extension. | |||
| 69 | + | // Used for favicon export for tools like rofi/fuzzel that require PNG format. | |||
| 70 | + | func SanitizeDomainForPNG(domain string) string { | |||
| 71 | + | return sanitizeDomain(domain) + ".png" | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // sanitizeDomain replaces unsafe filesystem characters with underscores. | |||
| 75 | + | func sanitizeDomain(domain string) string { | |||
| 65 | replacer := strings.NewReplacer( | 76 | replacer := strings.NewReplacer( | ||
| 66 | ":", "_", | 77 | ":", "_", | ||
| 67 | "/", "_", | 78 | "/", "_", | ||
| @@ -73,5 +84,5 @@ func SanitizeDomainForFilename(domain string) string { | |||||
| 73 | ">", "_", | 84 | ">", "_", | ||
| 74 | "|", "_", | 85 | "|", "_", | ||
| 75 | ) | 86 | ) | ||
| 76 | - | return replacer.Replace(domain) + ".ico" | 87 | + | return replacer.Replace(domain) |
| 77 | } | 88 | } | ||
M internal/infrastructure/favicon/cache.go +49 -1
| @@ -93,7 +93,7 @@ func (c *Cache) Set(domain string, data []byte) { | |||
| 93 | 93 | c.queueDiskWrite(domain, data) | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | - | // DiskPath returns the filesystem path for a domain's cached favicon. | |
| 96 | + | // DiskPath returns the filesystem path for a domain's cached favicon (ICO format). | |
| 97 | 97 | // Returns empty string if disk caching is disabled or domain is empty. | |
| 98 | 98 | func (c *Cache) DiskPath(domain string) string { | |
| 99 | 99 | if c.diskDir == "" || domain == "" { | |
| @@ -103,6 +103,54 @@ func (c *Cache) DiskPath(domain string) string { | |||
| 103 | 103 | return filepath.Join(c.diskDir, filename) | |
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | + | // DiskPathPNG returns the filesystem path for a domain's PNG favicon. | |
| 107 | + | // PNG format is required by external tools like rofi/fuzzel. | |
| 108 | + | // Returns empty string if disk caching is disabled or domain is empty. | |
| 109 | + | func (c *Cache) DiskPathPNG(domain string) string { | |
| 110 | + | if c.diskDir == "" || domain == "" { | |
| 111 | + | return "" | |
| 112 | + | } | |
| 113 | + | filename := domainurl.SanitizeDomainForPNG(domain) | |
| 114 | + | return filepath.Join(c.diskDir, filename) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // HasPNGOnDisk checks if a PNG favicon exists on disk for the given domain. | |
| 118 | + | func (c *Cache) HasPNGOnDisk(domain string) bool { | |
| 119 | + | path := c.DiskPathPNG(domain) | |
| 120 | + | if path == "" { | |
| 121 | + | return false | |
| 122 | + | } | |
| 123 | + | _, err := os.Stat(path) | |
| 124 | + | return err == nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // WritePNG writes raw PNG data to disk for a domain. | |
| 128 | + | // Used by UI layer to export WebKit textures for CLI tools. | |
| 129 | + | func (c *Cache) WritePNG(domain string, pngData []byte) { | |
| 130 | + | if c.diskDir == "" || len(pngData) == 0 || domain == "" { | |
| 131 | + | return | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // Ensure directory exists | |
| 135 | + | if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil { | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | filename := domainurl.SanitizeDomainForPNG(domain) | |
| 140 | + | finalPath := filepath.Join(c.diskDir, filename) | |
| 141 | + | tempPath := finalPath + ".tmp" | |
| 142 | + | ||
| 143 | + | // Write to temp file | |
| 144 | + | if err := os.WriteFile(tempPath, pngData, diskCacheFilePerm); err != nil { | |
| 145 | + | return | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // Atomic rename | |
| 149 | + | if err := os.Rename(tempPath, finalPath); err != nil { | |
| 150 | + | _ = os.Remove(tempPath) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 106 | 154 | // HasOnDisk checks if a favicon exists on disk for the given domain. | |
| 107 | 155 | func (c *Cache) HasOnDisk(domain string) bool { | |
| 108 | 156 | path := c.DiskPath(domain) | |
| @@ -93,7 +93,7 @@ func (c *Cache) Set(domain string, data []byte) { | |||||
| 93 | c.queueDiskWrite(domain, data) | 93 | c.queueDiskWrite(domain, data) | ||
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | - | // DiskPath returns the filesystem path for a domain's cached favicon. | 96 | + | // DiskPath returns the filesystem path for a domain's cached favicon (ICO format). |
| 97 | // Returns empty string if disk caching is disabled or domain is empty. | 97 | // Returns empty string if disk caching is disabled or domain is empty. | ||
| 98 | func (c *Cache) DiskPath(domain string) string { | 98 | func (c *Cache) DiskPath(domain string) string { | ||
| 99 | if c.diskDir == "" || domain == "" { | 99 | if c.diskDir == "" || domain == "" { | ||
| @@ -103,6 +103,54 @@ func (c *Cache) DiskPath(domain string) string { | |||||
| 103 | return filepath.Join(c.diskDir, filename) | 103 | return filepath.Join(c.diskDir, filename) | ||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | + | // DiskPathPNG returns the filesystem path for a domain's PNG favicon. | |||
| 107 | + | // PNG format is required by external tools like rofi/fuzzel. | |||
| 108 | + | // Returns empty string if disk caching is disabled or domain is empty. | |||
| 109 | + | func (c *Cache) DiskPathPNG(domain string) string { | |||
| 110 | + | if c.diskDir == "" || domain == "" { | |||
| 111 | + | return "" | |||
| 112 | + | } | |||
| 113 | + | filename := domainurl.SanitizeDomainForPNG(domain) | |||
| 114 | + | return filepath.Join(c.diskDir, filename) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // HasPNGOnDisk checks if a PNG favicon exists on disk for the given domain. | |||
| 118 | + | func (c *Cache) HasPNGOnDisk(domain string) bool { | |||
| 119 | + | path := c.DiskPathPNG(domain) | |||
| 120 | + | if path == "" { | |||
| 121 | + | return false | |||
| 122 | + | } | |||
| 123 | + | _, err := os.Stat(path) | |||
| 124 | + | return err == nil | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // WritePNG writes raw PNG data to disk for a domain. | |||
| 128 | + | // Used by UI layer to export WebKit textures for CLI tools. | |||
| 129 | + | func (c *Cache) WritePNG(domain string, pngData []byte) { | |||
| 130 | + | if c.diskDir == "" || len(pngData) == 0 || domain == "" { | |||
| 131 | + | return | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // Ensure directory exists | |||
| 135 | + | if err := os.MkdirAll(c.diskDir, diskCacheDirPerm); err != nil { | |||
| 136 | + | return | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | filename := domainurl.SanitizeDomainForPNG(domain) | |||
| 140 | + | finalPath := filepath.Join(c.diskDir, filename) | |||
| 141 | + | tempPath := finalPath + ".tmp" | |||
| 142 | + | ||||
| 143 | + | // Write to temp file | |||
| 144 | + | if err := os.WriteFile(tempPath, pngData, diskCacheFilePerm); err != nil { | |||
| 145 | + | return | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // Atomic rename | |||
| 149 | + | if err := os.Rename(tempPath, finalPath); err != nil { | |||
| 150 | + | _ = os.Remove(tempPath) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 106 | // HasOnDisk checks if a favicon exists on disk for the given domain. | 154 | // HasOnDisk checks if a favicon exists on disk for the given domain. | ||
| 107 | func (c *Cache) HasOnDisk(domain string) bool { | 155 | func (c *Cache) HasOnDisk(domain string) bool { | ||
| 108 | path := c.DiskPath(domain) | 156 | path := c.DiskPath(domain) | ||
M internal/infrastructure/favicon/service.go +16 -1
| @@ -59,11 +59,26 @@ func (s *Service) Store(domain string, data []byte) error { | |||
| 59 | 59 | return nil | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | - | // DiskPath returns the filesystem path where a domain's favicon is cached. | |
| 62 | + | // DiskPath returns the filesystem path where a domain's favicon is cached (ICO). | |
| 63 | 63 | func (s *Service) DiskPath(domain string) string { | |
| 64 | 64 | return s.cache.DiskPath(domain) | |
| 65 | 65 | } | |
| 66 | 66 | ||
| 67 | + | // DiskPathPNG returns the filesystem path for PNG favicon. | |
| 68 | + | func (s *Service) DiskPathPNG(domain string) string { | |
| 69 | + | return s.cache.DiskPathPNG(domain) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // HasPNGOnDisk checks if a PNG favicon exists on disk for the domain. | |
| 73 | + | func (s *Service) HasPNGOnDisk(domain string) bool { | |
| 74 | + | return s.cache.HasPNGOnDisk(domain) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // WritePNG writes raw PNG data to disk for a domain. | |
| 78 | + | func (s *Service) WritePNG(domain string, pngData []byte) { | |
| 79 | + | s.cache.WritePNG(domain, pngData) | |
| 80 | + | } | |
| 81 | + | ||
| 67 | 82 | // Close shuts down background workers and releases resources. | |
| 68 | 83 | func (s *Service) Close() { | |
| 69 | 84 | s.cache.Close() | |
| @@ -59,11 +59,26 @@ func (s *Service) Store(domain string, data []byte) error { | |||||
| 59 | return nil | 59 | return nil | ||
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | - | // DiskPath returns the filesystem path where a domain's favicon is cached. | 62 | + | // DiskPath returns the filesystem path where a domain's favicon is cached (ICO). |
| 63 | func (s *Service) DiskPath(domain string) string { | 63 | func (s *Service) DiskPath(domain string) string { | ||
| 64 | return s.cache.DiskPath(domain) | 64 | return s.cache.DiskPath(domain) | ||
| 65 | } | 65 | } | ||
| 66 | 66 | ||||
| 67 | + | // DiskPathPNG returns the filesystem path for PNG favicon. | |||
| 68 | + | func (s *Service) DiskPathPNG(domain string) string { | |||
| 69 | + | return s.cache.DiskPathPNG(domain) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // HasPNGOnDisk checks if a PNG favicon exists on disk for the domain. | |||
| 73 | + | func (s *Service) HasPNGOnDisk(domain string) bool { | |||
| 74 | + | return s.cache.HasPNGOnDisk(domain) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // WritePNG writes raw PNG data to disk for a domain. | |||
| 78 | + | func (s *Service) WritePNG(domain string, pngData []byte) { | |||
| 79 | + | s.cache.WritePNG(domain, pngData) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 67 | // Close shuts down background workers and releases resources. | 82 | // Close shuts down background workers and releases resources. | ||
| 68 | func (s *Service) Close() { | 83 | func (s *Service) Close() { | ||
| 69 | s.cache.Close() | 84 | s.cache.Close() | ||
M internal/ui/adapter/favicon.go +14 -0
| @@ -151,6 +151,13 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te | |||
| 151 | 151 | // Store in texture cache | |
| 152 | 152 | a.setTexture(domain, texture) | |
| 153 | 153 | ||
| 154 | + | // Export as PNG for CLI tools (rofi/fuzzel) - do this on main thread | |
| 155 | + | // since GTK texture operations need to happen there | |
| 156 | + | pngPath := a.service.DiskPathPNG(domain) | |
| 157 | + | if pngPath != "" && !a.service.HasPNGOnDisk(domain) { | |
| 158 | + | texture.SaveToPng(pngPath) | |
| 159 | + | } | |
| 160 | + | ||
| 154 | 161 | // Ensure disk cache is populated (async, in background) | |
| 155 | 162 | go a.service.EnsureDiskCache(ctx, domain) | |
| 156 | 163 | } | |
| @@ -168,6 +175,13 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin( | |||
| 168 | 175 | currentDomain := domainurl.ExtractDomain(currentURL) | |
| 169 | 176 | if originDomain != "" && originDomain != currentDomain { | |
| 170 | 177 | a.setTexture(originDomain, texture) | |
| 178 | + | ||
| 179 | + | // Export PNG for origin domain too | |
| 180 | + | pngPath := a.service.DiskPathPNG(originDomain) | |
| 181 | + | if pngPath != "" && !a.service.HasPNGOnDisk(originDomain) { | |
| 182 | + | texture.SaveToPng(pngPath) | |
| 183 | + | } | |
| 184 | + | ||
| 171 | 185 | go a.service.EnsureDiskCache(ctx, originDomain) | |
| 172 | 186 | } | |
| 173 | 187 | } | |
| @@ -151,6 +151,13 @@ func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, te | |||||
| 151 | // Store in texture cache | 151 | // Store in texture cache | ||
| 152 | a.setTexture(domain, texture) | 152 | a.setTexture(domain, texture) | ||
| 153 | 153 | ||||
| 154 | + | // Export as PNG for CLI tools (rofi/fuzzel) - do this on main thread | |||
| 155 | + | // since GTK texture operations need to happen there | |||
| 156 | + | pngPath := a.service.DiskPathPNG(domain) | |||
| 157 | + | if pngPath != "" && !a.service.HasPNGOnDisk(domain) { | |||
| 158 | + | texture.SaveToPng(pngPath) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 154 | // Ensure disk cache is populated (async, in background) | 161 | // Ensure disk cache is populated (async, in background) | ||
| 155 | go a.service.EnsureDiskCache(ctx, domain) | 162 | go a.service.EnsureDiskCache(ctx, domain) | ||
| 156 | } | 163 | } | ||
| @@ -168,6 +175,13 @@ func (a *FaviconAdapter) StoreFromWebKitWithOrigin( | |||||
| 168 | currentDomain := domainurl.ExtractDomain(currentURL) | 175 | currentDomain := domainurl.ExtractDomain(currentURL) | ||
| 169 | if originDomain != "" && originDomain != currentDomain { | 176 | if originDomain != "" && originDomain != currentDomain { | ||
| 170 | a.setTexture(originDomain, texture) | 177 | a.setTexture(originDomain, texture) | ||
| 178 | + | ||||
| 179 | + | // Export PNG for origin domain too | |||
| 180 | + | pngPath := a.service.DiskPathPNG(originDomain) | |||
| 181 | + | if pngPath != "" && !a.service.HasPNGOnDisk(originDomain) { | |||
| 182 | + | texture.SaveToPng(pngPath) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 171 | go a.service.EnsureDiskCache(ctx, originDomain) | 185 | go a.service.EnsureDiskCache(ctx, originDomain) | ||
| 172 | } | 186 | } | ||
| 173 | } | 187 | } | ||