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