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