1// Package services contains application services that orchestrate business logic.
2package services
3
4import (
5 "context"
6 "database/sql"
7 "embed"
8 "fmt"
9 "math"
10 neturl "net/url"
11 "sync"
12 "time"
13
14 "github.com/bnema/dumber/internal/cache"
15 "github.com/bnema/dumber/internal/config"
16 "github.com/bnema/dumber/internal/db"
17 "github.com/bnema/dumber/internal/logging"
18 "github.com/bnema/dumber/internal/parser"
19 "github.com/bnema/dumber/pkg/webkit"
20)
21
22func clampToInt32(i int64) int32 {
23 if i > math.MaxInt32 {
24 i = math.MaxInt32
25 }
26 if i < math.MinInt32 {
27 i = math.MinInt32
28 }
29 return int32(i) //nolint:gosec // value is clamped to int32 bounds
30}
31
32// WindowTitleUpdater interface allows the service to update the window title
33// Decoupled from any specific GUI framework.
34type WindowTitleUpdater interface {
35 SetTitle(title string)
36}
37
38// historyUpdate represents a pending history write operation
39type historyUpdate struct {
40 url string
41 title sql.NullString
42}
43
44// BrowserService handles browser-related operations for the built-in browser.
45type BrowserService struct {
46 config *config.Config
47 dbQueries db.DatabaseQuerier
48 windowTitleUpdater WindowTitleUpdater
49 webView *webkit.WebView
50 initialURL string
51 guiBundle string
52 zoomCache *cache.ZoomCache // In-memory cache for zoom levels
53 certCache *cache.CertValidationsCache // In-memory cache for certificate validations
54 favoritesCache *cache.FavoritesCache // In-memory cache for favorites
55 fuzzyCache *cache.CacheManager // Async fuzzy search cache for dmenu
56 historyQueue chan historyUpdate // Queue for batched history writes
57 historyFlushDone chan bool // Signal when history flush is complete (buffered)
58 flushOnce sync.Once // Ensures FlushHistoryQueue only runs once
59
60 // Fuzzy cache refresh tracking
61 historySinceRefresh int // Count of history entries added since last cache refresh
62 lastCacheRefresh time.Time // Timestamp of last fuzzy cache refresh
63}
64
65// ServiceName returns the service name for frontend binding
66func (s *BrowserService) ServiceName() string {
67 return "BrowserService"
68}
69
70// NavigationResult represents the result of a navigation operation.
71type NavigationResult struct {
72 URL string `json:"url"`
73 Title string `json:"title"`
74 Success bool `json:"success"`
75 Error string `json:"error,omitempty"`
76 LoadTime time.Duration `json:"load_time"`
77 RedirectChain []string `json:"redirect_chain,omitempty"`
78}
79
80// HistoryEntry represents a simplified history entry for frontend.
81type HistoryEntry struct {
82 ID int64 `json:"id"`
83 URL string `json:"url"`
84 Title string `json:"title"`
85 FaviconURL string `json:"favicon_url"`
86 VisitCount int32 `json:"visit_count"`
87 LastVisited time.Time `json:"last_visited"`
88 CreatedAt time.Time `json:"created_at"`
89}
90
91// NewBrowserService creates a new BrowserService instance.
92func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService {
93 s := &BrowserService{
94 config: cfg,
95 dbQueries: queries,
96 windowTitleUpdater: nil,
97 webView: nil,
98 initialURL: "",
99 guiBundle: "",
100 zoomCache: cache.NewZoomCache(queries),
101 certCache: cache.NewCertValidationsCache(queries),
102 favoritesCache: cache.NewFavoritesCache(queries),
103 fuzzyCache: cache.NewCacheManager(queries, nil), // nil = use defaults
104 historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates
105 historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking
106 historySinceRefresh: 0,
107 lastCacheRefresh: time.Now(), // Initialize to now
108 }
109
110 // Start background batch processor for history writes
111 go s.processHistoryQueue()
112
113 return s
114}
115
116// SetWindowTitleUpdater sets the window title updater interface
117func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) {
118 s.windowTitleUpdater = updater
119}
120
121// AttachWebView connects a native WebKit WebView to this service for integration.
122func (s *BrowserService) AttachWebView(view *webkit.WebView) {
123 s.webView = view
124 // Favicon handling is now managed by FaviconService
125}
126
127// LoadGUIBundle loads the unified GUI bundle from assets
128func (s *BrowserService) LoadGUIBundle(assets embed.FS) error {
129 logging.Debug(fmt.Sprintf("[browser] Attempting to load GUI bundle from assets/gui/gui.min.js"))
130 bundleBytes, err := assets.ReadFile("assets/gui/gui.min.js")
131 if err != nil {
132 logging.Error(fmt.Sprintf("[browser] ERROR: Failed to load GUI bundle: %v", err))
133 return fmt.Errorf("failed to load GUI bundle: %w", err)
134 }
135 s.guiBundle = string(bundleBytes)
136 logging.Info(fmt.Sprintf("[browser] GUI bundle loaded successfully into browser service, size: %d bytes", len(bundleBytes)))
137 return nil
138}
139
140// GetGUIBundle returns the loaded GUI bundle string
141func (s *BrowserService) GetGUIBundle() string {
142 return s.guiBundle
143}
144
145// InjectToastSystem injects the GUI bundle and initializes the toast system
146func (s *BrowserService) InjectToastSystem(ctx context.Context, theme string) error {
147 _ = ctx
148 if s.webView == nil {
149 return fmt.Errorf("webview not attached")
150 }
151 if s.guiBundle == "" {
152 return fmt.Errorf("GUI bundle not loaded")
153 }
154
155 // Set theme if provided
156 themeScript := ""
157 if theme != "" {
158 themeScript = fmt.Sprintf("window.__dumber_initial_theme = '%s';", theme)
159 }
160
161 // Inject the unified GUI bundle and initialize toast system after DOM ready
162 toastScript := s.guiBundle + ";" + themeScript +
163 "(function(){" +
164 "function initToast(){" +
165 "if(window.__dumber_gui && window.__dumber_gui.initializeToast){" +
166 "window.__dumber_gui.initializeToast().then(function(){" +
167 "console.log('✅ Toast system initialized via GUI bundle');" +
168 "}).catch(function(e){console.error('❌ Toast initialization failed:', e);});" +
169 "}else{console.error('❌ GUI bundle not properly loaded');}" +
170 "}" +
171 "if(document.readyState==='loading'){" +
172 "document.addEventListener('DOMContentLoaded',initToast);" +
173 "}else{initToast();}" +
174 "})();"
175
176 return s.webView.InjectScript(toastScript)
177}
178
179// Navigate handles navigation to a URL and records it in history.
180func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) {
181 startTime := time.Now()
182
183 if url == "" {
184 return &NavigationResult{
185 Success: false,
186 Error: "URL cannot be empty",
187 }, nil
188 }
189
190 // Store the initial URL for frontend synchronization
191 if s.initialURL == "" {
192 s.initialURL = url
193 }
194
195 // Record the navigation in history
196 err := s.recordHistory(ctx, url, "")
197 if err != nil {
198 // Don't fail navigation if history recording fails
199 fmt.Printf("Failed to record history: %v\n", err)
200 }
201
202 // Favicon detection is now handled automatically by WebKit's native favicon database
203
204 return &NavigationResult{
205 URL: url,
206 Success: true,
207 LoadTime: time.Since(startTime),
208 }, nil
209}
210
211// UpdatePageTitle updates the title of the current page in history and the window title.
212func (s *BrowserService) UpdatePageTitle(ctx context.Context, url, title string) error {
213 if url == "" || title == "" {
214 return fmt.Errorf("URL and title cannot be empty")
215 }
216
217 normalizedURL := parser.NormalizeHistoryURL(url)
218
219 // Use AddOrUpdateHistory to update the title
220 titleNull := sql.NullString{String: title, Valid: true}
221 err := s.dbQueries.AddOrUpdateHistory(ctx, normalizedURL, titleNull)
222 if err != nil {
223 return err
224 }
225
226 // Update the window title if we have a title updater
227 if s.windowTitleUpdater != nil && title != "" {
228 windowTitle := fmt.Sprintf("Dumber - %s", title)
229 updater := s.windowTitleUpdater
230 if s.webView != nil {
231 s.webView.RunOnMainThread(func() {
232 updater.SetTitle(windowTitle)
233 })
234 } else {
235 updater.SetTitle(windowTitle)
236 }
237 }
238
239 return nil
240}
241
242// GetRecentHistory returns recent browser history entries.
243func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]HistoryEntry, error) {
244 if limit <= 0 {
245 limit = 50 // Default limit
246 }
247
248 entries, err := s.dbQueries.GetHistory(ctx, int64(limit))
249 if err != nil {
250 return nil, err
251 }
252
253 result := make([]HistoryEntry, len(entries))
254 for i, entry := range entries {
255 // Defensive cast to int32 to prevent overflow
256 vc := clampToInt32(entry.VisitCount.Int64)
257 result[i] = HistoryEntry{
258 ID: entry.ID,
259 URL: entry.Url,
260 Title: entry.Title.String,
261 FaviconURL: entry.FaviconUrl.String,
262 VisitCount: vc,
263 LastVisited: entry.LastVisited.Time,
264 CreatedAt: entry.CreatedAt.Time,
265 }
266 }
267
268 return result, nil
269}
270
271// GetRecentHistoryWithOffset returns recent browser history entries with pagination support.
272func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit, offset int) ([]HistoryEntry, error) {
273 if limit <= 0 {
274 limit = 20 // Default limit for pagination
275 }
276 if offset < 0 {
277 offset = 0
278 }
279
280 entries, err := s.dbQueries.GetHistoryWithOffset(ctx, int64(limit), int64(offset))
281 if err != nil {
282 return nil, err
283 }
284
285 result := make([]HistoryEntry, len(entries))
286 for i, entry := range entries {
287 // Defensive cast to int32 to prevent overflow
288 vc := clampToInt32(entry.VisitCount.Int64)
289 result[i] = HistoryEntry{
290 ID: entry.ID,
291 URL: entry.Url,
292 Title: entry.Title.String,
293 FaviconURL: entry.FaviconUrl.String,
294 VisitCount: vc,
295 LastVisited: entry.LastVisited.Time,
296 CreatedAt: entry.CreatedAt.Time,
297 }
298 }
299
300 return result, nil
301}
302
303// GetMostVisited returns the most visited browser history entries.
304func (s *BrowserService) GetMostVisited(ctx context.Context, limit int) ([]HistoryEntry, error) {
305 if limit <= 0 {
306 limit = 10 // Default limit
307 }
308
309 entries, err := s.dbQueries.GetMostVisited(ctx, int64(limit))
310 if err != nil {
311 return nil, err
312 }
313
314 result := make([]HistoryEntry, len(entries))
315 for i, entry := range entries {
316 // Defensive cast to int32 to prevent overflow
317 vc := clampToInt32(entry.VisitCount.Int64)
318 result[i] = HistoryEntry{
319 ID: entry.ID,
320 URL: entry.Url,
321 Title: entry.Title.String,
322 FaviconURL: entry.FaviconUrl.String,
323 VisitCount: vc,
324 LastVisited: entry.LastVisited.Time,
325 CreatedAt: entry.CreatedAt.Time,
326 }
327 }
328
329 return result, nil
330}
331
332// SearchHistory searches browser history.
333func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {
334 if query == "" {
335 return s.GetRecentHistory(ctx, limit)
336 }
337
338 if limit <= 0 {
339 limit = 50
340 }
341
342 urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
343 titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
344
345 entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
346 if err != nil {
347 return nil, err
348 }
349
350 result := make([]HistoryEntry, len(entries))
351 for i, entry := range entries {
352 vc := clampToInt32(entry.VisitCount.Int64)
353 // For omnibox display, convert favicon URI to file path when available
354 faviconURL := entry.FaviconUrl.String
355 result[i] = HistoryEntry{
356 ID: entry.ID,
357 URL: entry.Url,
358 Title: entry.Title.String,
359 FaviconURL: faviconURL,
360 VisitCount: vc,
361 LastVisited: entry.LastVisited.Time,
362 CreatedAt: entry.CreatedAt.Time,
363 }
364 }
365
366 return result, nil
367}
368
369// GetBestPrefixMatch returns the highest-scoring URL that starts with the given prefix.
370// Used for fish-style inline autosuggestions in the omnibox.
371func (s *BrowserService) GetBestPrefixMatch(ctx context.Context, prefix string) string {
372 if s.fuzzyCache == nil || prefix == "" {
373 return ""
374 }
375 return s.fuzzyCache.GetBestPrefixMatch(ctx, prefix)
376}
377
378// DeleteHistoryEntry removes a specific history entry.
379func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
380 return s.dbQueries.DeleteHistory(ctx, id)
381}
382
383// ClearHistory removes all history entries.
384func (s *BrowserService) ClearHistory(ctx context.Context) error {
385 _ = ctx
386 // Note: ClearAllHistory method doesn't exist in current schema
387 // This would need to be implemented in the database layer
388 return fmt.Errorf("clear all history not implemented yet")
389}
390
391// GetHistoryStats returns statistics about browser history.
392func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) {
393 // Get recent entries for basic stats
394 recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit)
395 if err != nil {
396 return nil, err
397 }
398
399 // Calculate basic statistics
400 stats := map[string]interface{}{
401 "total_entries": len(recentEntries),
402 "recent_count": len(recentEntries),
403 }
404
405 // Add more detailed stats if we have entries
406 if len(recentEntries) > 0 {
407 stats["oldest_entry"] = recentEntries[len(recentEntries)-1].CreatedAt.Time
408 stats["newest_entry"] = recentEntries[0].CreatedAt.Time
409 }
410
411 return stats, nil
412}
413
414// GetConfig returns the current browser configuration.
415func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) {
416 _ = ctx
417 return s.config, nil
418}
419
420// UpdateConfig updates the browser configuration.
421func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error {
422 _ = ctx
423 // In a real implementation, you'd want to validate and persist the config
424 s.config = newConfig
425 return nil
426}
427
428// GetSearchShortcuts returns available search shortcuts.
429func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
430 return s.config.SearchShortcuts, nil
431}
432
433// recordHistory adds or updates a history entry.
434// Now queues the update for batched processing instead of immediate DB write.
435func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {
436 normalizedURL := parser.NormalizeHistoryURL(url)
437
438 titleNull := sql.NullString{Valid: false}
439 if title != "" {
440 titleNull = sql.NullString{String: title, Valid: true}
441 }
442
443 // Queue the history update (non-blocking with buffer)
444 select {
445 case s.historyQueue <- historyUpdate{url: normalizedURL, title: titleNull}:
446 // Successfully queued
447 return nil
448 default:
449 // Queue full, write directly (fallback)
450 logging.Warn(fmt.Sprintf("Warning: history queue full, writing directly"))
451 return s.dbQueries.AddOrUpdateHistory(ctx, normalizedURL, titleNull)
452 }
453}
454
455// processHistoryQueue processes batched history writes in the background.
456// Flushes every 5 seconds or when a batch reaches 50 items.
457func (s *BrowserService) processHistoryQueue() {
458 ticker := time.NewTicker(5 * time.Second)
459 defer ticker.Stop()
460
461 batch := make([]historyUpdate, 0, 50)
462 const maxBatchSize = 50
463
464 flush := func() {
465 if len(batch) == 0 {
466 return
467 }
468
469 ctx := context.Background()
470 // Process all updates in the batch
471 // Note: We can't use a transaction with sqlc-generated code easily,
472 // but we can still batch the operations to reduce overhead
473 for _, update := range batch {
474 if err := s.dbQueries.AddOrUpdateHistory(ctx, update.url, update.title); err != nil {
475 logging.Warn(fmt.Sprintf("Warning: failed to write history for %s: %v", update.url, err))
476 }
477 }
478
479 logging.Debug(fmt.Sprintf("Flushed %d history updates to database", len(batch)))
480
481 // Track history entries for fuzzy cache refresh
482 batchSize := len(batch)
483 batch = batch[:0] // Clear batch
484
485 // Smart fuzzy cache refresh: rebuild after N entries AND M minutes
486 const minEntries = 10 // Minimum new entries before refresh
487 const minInterval = 5 * time.Minute // Minimum time between refreshes
488
489 s.historySinceRefresh += batchSize
490 timeSinceRefresh := time.Since(s.lastCacheRefresh)
491
492 if s.historySinceRefresh >= minEntries && timeSinceRefresh >= minInterval {
493 logging.Debug(fmt.Sprintf("[cache] Triggering async fuzzy cache refresh (%d new entries, %v since last refresh)",
494 s.historySinceRefresh, timeSinceRefresh))
495
496 // Async refresh in background (non-blocking)
497 s.fuzzyCache.InvalidateAndRefresh(ctx)
498
499 // Reset counters
500 s.historySinceRefresh = 0
501 s.lastCacheRefresh = time.Now()
502 }
503 }
504
505 for {
506 select {
507 case update, ok := <-s.historyQueue:
508 if !ok {
509 // Channel closed, flush remaining and exit
510 flush()
511 s.historyFlushDone <- true // Send signal (non-blocking due to buffer)
512 return
513 }
514 batch = append(batch, update)
515 if len(batch) >= maxBatchSize {
516 flush()
517 }
518
519 case <-ticker.C:
520 flush()
521 }
522 }
523}
524
525// FlushHistoryQueue flushes any pending history writes and stops the processor.
526// Call this during graceful shutdown. Safe to call multiple times.
527// Respects context cancellation with a timeout to prevent hanging.
528func (s *BrowserService) FlushHistoryQueue(ctx context.Context) error {
529 var flushErr error
530 s.flushOnce.Do(func() {
531 close(s.historyQueue)
532
533 // Wait for completion with context cancellation support
534 select {
535 case <-s.historyFlushDone:
536 logging.Info(fmt.Sprintf("History queue flushed and closed"))
537 case <-ctx.Done():
538 flushErr = fmt.Errorf("history queue flush cancelled: %w", ctx.Err())
539 logging.Warn(fmt.Sprintf("Warning: %v", flushErr))
540 }
541 })
542 return flushErr
543}
544
545// Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%
546var firefoxZoomLevels = []float64{0.3, 0.5, 0.67, 0.8, 0.9, 1.0, 1.1, 1.2, 1.33, 1.5, 1.7, 2.0, 2.4, 3.0, 4.0, 5.0}
547
548// ZoomIn increases the zoom level to the next Firefox zoom level.
549func (s *BrowserService) ZoomIn(ctx context.Context, url string) (float64, error) {
550 currentZoom, err := s.GetZoomLevel(ctx, url)
551 if err != nil {
552 return 1.0, err
553 }
554
555 newZoom := s.getNextZoomLevel(currentZoom, true)
556 err = s.SetZoomLevel(ctx, url, newZoom)
557 if err != nil {
558 return currentZoom, err
559 }
560
561 return newZoom, nil
562}
563
564// ZoomOut decreases the zoom level to the previous Firefox zoom level.
565func (s *BrowserService) ZoomOut(ctx context.Context, url string) (float64, error) {
566 currentZoom, err := s.GetZoomLevel(ctx, url)
567 if err != nil {
568 return 1.0, err
569 }
570
571 newZoom := s.getNextZoomLevel(currentZoom, false)
572 err = s.SetZoomLevel(ctx, url, newZoom)
573 if err != nil {
574 return currentZoom, err
575 }
576
577 return newZoom, nil
578}
579
580// ResetZoom resets the zoom level to 1.0 for the current URL.
581func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, error) {
582 return s.setZoom(ctx, url, 1.0)
583}
584
585// LoadZoomCacheFromDB loads all zoom levels from database into memory cache on startup.
586// This eliminates database reads during page transitions.
587func (s *BrowserService) LoadZoomCacheFromDB(ctx context.Context) error {
588 if err := s.zoomCache.Load(ctx); err != nil {
589 return fmt.Errorf("failed to load zoom levels: %w", err)
590 }
591
592 loadedCount := len(s.zoomCache.List())
593 logging.Info(fmt.Sprintf("Loaded %d zoom levels into cache", loadedCount))
594 return nil
595}
596
597// LoadCertCacheFromDB loads all certificate validations from the database into the cache.
598// This is typically called once at startup for fast RAM-first access.
599func (s *BrowserService) LoadCertCacheFromDB(ctx context.Context) error {
600 if err := s.certCache.Load(ctx); err != nil {
601 return fmt.Errorf("failed to load certificate validations: %w", err)
602 }
603
604 loadedCount := len(s.certCache.List())
605 logging.Info(fmt.Sprintf("Loaded %d certificate validations into cache", loadedCount))
606 return nil
607}
608
609// LoadFuzzyCacheFromDB builds or loads the fuzzy search cache from database history.
610// This ensures dmenu has instant access to pre-built search structures.
611func (s *BrowserService) LoadFuzzyCacheFromDB(ctx context.Context) error {
612 // GetCache automatically loads from binary file if valid, or builds from DB
613 _, err := s.fuzzyCache.GetCache(ctx)
614 if err != nil {
615 return fmt.Errorf("failed to load fuzzy cache: %w", err)
616 }
617
618 stats := s.fuzzyCache.Stats()
619 logging.Info(fmt.Sprintf("Loaded fuzzy cache: %d entries, %d trigrams",
620 stats.EntryCount, stats.TrigramCount))
621 return nil
622}
623
624// LoadFavoritesCacheFromDB loads all favorites from the database into the cache.
625// This is typically called once at startup for fast RAM-first access.
626func (s *BrowserService) LoadFavoritesCacheFromDB(ctx context.Context) error {
627 if err := s.favoritesCache.Load(ctx); err != nil {
628 return fmt.Errorf("failed to load favorites: %w", err)
629 }
630
631 loadedCount := len(s.favoritesCache.List())
632 logging.Info(fmt.Sprintf("Loaded %d favorites into cache", loadedCount))
633 return nil
634}
635
636// FavoriteEntry represents a favorite for the frontend (similar to HistoryEntry).
637type FavoriteEntry struct {
638 ID int64 `json:"id"`
639 URL string `json:"url"`
640 Title string `json:"title"`
641 FaviconURL string `json:"favicon_url"`
642 Position int64 `json:"position"`
643}
644
645// GetFavorites returns all favorites ordered by position.
646func (s *BrowserService) GetFavorites(ctx context.Context) ([]FavoriteEntry, error) {
647 favorites := s.favoritesCache.GetAll()
648
649 result := make([]FavoriteEntry, len(favorites))
650 for i, fav := range favorites {
651 result[i] = FavoriteEntry{
652 ID: fav.ID,
653 URL: fav.Url,
654 Title: fav.Title.String,
655 FaviconURL: fav.FaviconUrl.String,
656 Position: fav.Position,
657 }
658 }
659
660 return result, nil
661}
662
663// IsFavorite checks if a URL is in the favorites list.
664func (s *BrowserService) IsFavorite(ctx context.Context, url string) (bool, error) {
665 if url == "" {
666 return false, nil
667 }
668
669 _, exists := s.favoritesCache.Get(url)
670 return exists, nil
671}
672
673// ToggleFavorite adds or removes a URL from favorites.
674// Returns true if the URL was added, false if it was removed.
675func (s *BrowserService) ToggleFavorite(ctx context.Context, url, title, faviconURL string) (bool, error) {
676 if url == "" {
677 return false, fmt.Errorf("URL cannot be empty")
678 }
679
680 // Check if already favorited
681 _, exists := s.favoritesCache.Get(url)
682
683 if exists {
684 // Remove from favorites
685 if err := s.favoritesCache.Delete(url); err != nil {
686 return false, fmt.Errorf("failed to remove favorite: %w", err)
687 }
688 logging.Info(fmt.Sprintf("Removed favorite: %s", url))
689 return false, nil
690 }
691
692 // Add to favorites
693 favorite := db.Favorite{
694 Url: url,
695 Title: sql.NullString{String: title, Valid: title != ""},
696 FaviconUrl: sql.NullString{String: faviconURL, Valid: faviconURL != ""},
697 }
698
699 if err := s.favoritesCache.Set(url, favorite); err != nil {
700 return false, fmt.Errorf("failed to add favorite: %w", err)
701 }
702
703 logging.Info(fmt.Sprintf("Added favorite: %s", url))
704 return true, nil
705}
706
707// FlushAllCaches flushes all in-memory caches to ensure pending writes complete.
708// This is typically called during shutdown to prevent data loss.
709func (s *BrowserService) FlushAllCaches(ctx context.Context) error {
710 // Flush zoom cache
711 if err := s.zoomCache.Flush(); err != nil {
712 return fmt.Errorf("failed to flush zoom cache: %w", err)
713 }
714
715 // Flush certificate validation cache
716 if err := s.certCache.Flush(); err != nil {
717 return fmt.Errorf("failed to flush cert cache: %w", err)
718 }
719
720 // Flush favorites cache
721 if err := s.favoritesCache.Flush(); err != nil {
722 return fmt.Errorf("failed to flush favorites cache: %w", err)
723 }
724
725 // Save fuzzy cache to binary file for instant dmenu access
726 s.fuzzyCache.OnApplicationExit(ctx)
727
728 // Flush history queue (existing mechanism)
729 if err := s.FlushHistoryQueue(ctx); err != nil {
730 return fmt.Errorf("failed to flush history queue: %w", err)
731 }
732
733 logging.Info(fmt.Sprintf("All caches flushed successfully"))
734 return nil
735}
736
737// GetZoomLevel retrieves the saved zoom level for a URL.
738// Now reads from in-memory cache instead of database for instant access.
739func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
740 if url == "" {
741 return s.config.DefaultZoom, nil
742 }
743
744 key := zoomKeyFromURL(url)
745
746 // Check cache first (fast RAM lookup)
747 if zoom, ok := s.zoomCache.Get(key); ok {
748 return zoom, nil
749 }
750
751 // Fallback to exact URL record for backward compatibility (cache miss)
752 if key != url {
753 if zoom, ok := s.zoomCache.Get(url); ok {
754 return zoom, nil
755 }
756 }
757
758 // No zoom setting found, return configured default
759 return s.config.DefaultZoom, nil
760}
761
762// SetZoomLevel sets the zoom level for a URL.
763// Updates cache immediately (instant) and persists to DB asynchronously.
764func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {
765 if url == "" {
766 return fmt.Errorf("URL cannot be empty")
767 }
768
769 // Clamp zoom level to reasonable bounds
770 if zoomLevel < zoomMin {
771 zoomLevel = zoomMin
772 } else if zoomLevel > zoomMax {
773 zoomLevel = zoomMax
774 }
775
776 key := zoomKeyFromURL(url)
777
778 // Update cache immediately (fast, synchronous) and persist async
779 return s.zoomCache.Set(key, zoomLevel)
780}
781
782// GetCurrentURL returns the current URL (this would be implemented by the frontend)
783func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) {
784 if s.webView == nil {
785 return "", nil
786 }
787 return s.webView.GetCurrentURL(), nil
788}
789
790// GetInitialURL returns the initially navigated URL for frontend synchronization
791func (s *BrowserService) GetInitialURL(ctx context.Context) (string, error) {
792 return s.initialURL, nil
793}
794
795// CopyCurrentURL copies the current URL to clipboard (frontend-initiated)
796func (s *BrowserService) CopyCurrentURL(ctx context.Context, url string) error {
797 // This is primarily for logging/recording the copy action
798 // The actual clipboard operation is handled by the frontend
799 if url != "" {
800 // Could record this action in analytics if needed
801 fmt.Printf("URL copied to clipboard: %s\n", url)
802 }
803 return nil
804}
805
806// GoBack provides navigation back functionality
807func (s *BrowserService) GoBack(ctx context.Context) error {
808 if s.webView != nil {
809 return s.webView.GoBack()
810 }
811 fmt.Println("Navigation: Go back")
812 return nil
813}
814
815// GoForward provides navigation forward functionality
816func (s *BrowserService) GoForward(ctx context.Context) error {
817 if s.webView != nil {
818 return s.webView.GoForward()
819 }
820 fmt.Println("Navigation: Go forward")
821 return nil
822}
823
824// getNextZoomLevel returns the next Firefox zoom level in the specified direction
825const (
826 zoomMin = 0.25
827 zoomMax = 5.0
828 recentHistoryLimit = 1000
829)
830
831func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 {
832 // Find the closest current zoom level
833 closestIndex := 0
834 minDiff := 10.0 // Large initial value
835
836 for i, level := range firefoxZoomLevels {
837 diff := currentZoom - level
838 if diff < 0 {
839 diff = -diff // abs value
840 }
841 if diff < minDiff {
842 minDiff = diff
843 closestIndex = i
844 }
845 }
846
847 if zoomIn {
848 // Move to next higher zoom level
849 if closestIndex < len(firefoxZoomLevels)-1 {
850 return firefoxZoomLevels[closestIndex+1]
851 }
852 return firefoxZoomLevels[closestIndex] // Already at max
853 }
854 // Move to next lower zoom level
855 if closestIndex > 0 {
856 return firefoxZoomLevels[closestIndex-1]
857 }
858 return firefoxZoomLevels[closestIndex] // Already at min
859}
860
861// setZoom sets the zoom level to a specific value
862func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) {
863 err := s.SetZoomLevel(ctx, url, zoomLevel)
864 if err != nil {
865 return 1.0, err
866 }
867 return zoomLevel, nil
868}
869
870// zoomKeyFromURL extracts a stable per-domain key for zoom persistence.
871// Uses hostname if present; falls back to the full input URL string.
872func zoomKeyFromURL(raw string) string {
873 u, err := neturl.Parse(raw)
874 if err != nil || u.Host == "" {
875 return raw
876 }
877 return u.Host
878}
879
880// extractDomain extracts the domain from a URL for zoom level storage
881// Domain-based zoom allows settings to persist across different pages of the same site
882// extractDomain is no longer used; zoom is stored per URL for contract parity.
883
884// ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.
885func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }
886
887// ColorPalettesResponse holds light and dark palettes for JSON marshaling
888type ColorPalettesResponse struct {
889 Light config.ColorPalette `json:"light"`
890 Dark config.ColorPalette `json:"dark"`
891}
892
893// GetColorPalettesForMessaging returns the color palettes from config
894func (s *BrowserService) GetColorPalettesForMessaging() ColorPalettesResponse {
895 if s.config == nil {
896 return ColorPalettesResponse{}
897 }
898 return ColorPalettesResponse{
899 Light: s.config.Appearance.LightPalette,
900 Dark: s.config.Appearance.DarkPalette,
901 }
902}