1// Package services contains application services that orchestrate business logic.
2package services
3
4import (
5 "context"
6 "crypto/md5"
7 "database/sql"
8 "embed"
9 "errors"
10 "fmt"
11 "log"
12 "math"
13 neturl "net/url"
14 "time"
15
16 "github.com/bnema/dumber/internal/cache"
17 "github.com/bnema/dumber/internal/config"
18 "github.com/bnema/dumber/internal/db"
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// BrowserService handles browser-related operations for the built-in browser.
39type BrowserService struct {
40 config *config.Config
41 dbQueries db.DatabaseQuerier
42 windowTitleUpdater WindowTitleUpdater
43 webView *webkit.WebView
44 initialURL string
45 guiBundle string
46}
47
48// ServiceName returns the service name for frontend binding
49func (s *BrowserService) ServiceName() string {
50 return "BrowserService"
51}
52
53// NavigationResult represents the result of a navigation operation.
54type NavigationResult struct {
55 URL string `json:"url"`
56 Title string `json:"title"`
57 Success bool `json:"success"`
58 Error string `json:"error,omitempty"`
59 LoadTime time.Duration `json:"load_time"`
60 RedirectChain []string `json:"redirect_chain,omitempty"`
61}
62
63// HistoryEntry represents a simplified history entry for frontend.
64type HistoryEntry struct {
65 ID int64 `json:"id"`
66 URL string `json:"url"`
67 Title string `json:"title"`
68 FaviconURL string `json:"favicon_url"`
69 VisitCount int32 `json:"visit_count"`
70 LastVisited time.Time `json:"last_visited"`
71 CreatedAt time.Time `json:"created_at"`
72}
73
74// NewBrowserService creates a new BrowserService instance.
75func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService {
76 return &BrowserService{
77 config: cfg,
78 dbQueries: queries,
79 windowTitleUpdater: nil,
80 webView: nil,
81 initialURL: "",
82 guiBundle: "",
83 }
84}
85
86// SetWindowTitleUpdater sets the window title updater interface
87func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) {
88 s.windowTitleUpdater = updater
89}
90
91// AttachWebView connects a native WebKit WebView to this service for integration.
92func (s *BrowserService) AttachWebView(view *webkit.WebView) {
93 s.webView = view
94
95 // Register legacy favicon change handler (binary data - fallback)
96 // This is kept for backward compatibility but is rarely used
97 // Modern favicon handling uses the URI-based handler registered in buildPane()
98 view.RegisterFaviconChangedHandler(func(data []byte) {
99 if currentURL := view.GetCurrentURL(); currentURL != "" {
100 log.Printf("[favicon] Favicon detected for %s, data size: %d bytes", currentURL, len(data))
101 s.handleFaviconChanged(currentURL, data)
102 }
103 })
104
105 // Note: RegisterFaviconURIChangedHandler is now only called in buildPane()
106 // to avoid duplicate handler registration which causes multiple downloads
107 // of the same favicon and file handle race conditions
108}
109
110// LoadGUIBundle loads the unified GUI bundle from assets
111func (s *BrowserService) LoadGUIBundle(assets embed.FS) error {
112 log.Printf("[browser] Attempting to load GUI bundle from assets/gui/gui.min.js")
113 bundleBytes, err := assets.ReadFile("assets/gui/gui.min.js")
114 if err != nil {
115 log.Printf("[browser] ERROR: Failed to load GUI bundle: %v", err)
116 return fmt.Errorf("failed to load GUI bundle: %w", err)
117 }
118 s.guiBundle = string(bundleBytes)
119 log.Printf("[browser] GUI bundle loaded successfully into browser service, size: %d bytes", len(bundleBytes))
120 return nil
121}
122
123// GetGUIBundle returns the loaded GUI bundle string
124func (s *BrowserService) GetGUIBundle() string {
125 return s.guiBundle
126}
127
128// InjectToastSystem injects the GUI bundle and initializes the toast system
129func (s *BrowserService) InjectToastSystem(ctx context.Context, theme string) error {
130 _ = ctx
131 if s.webView == nil {
132 return fmt.Errorf("webview not attached")
133 }
134 if s.guiBundle == "" {
135 return fmt.Errorf("GUI bundle not loaded")
136 }
137
138 // Set theme if provided
139 themeScript := ""
140 if theme != "" {
141 themeScript = fmt.Sprintf("window.__dumber_initial_theme = '%s';", theme)
142 }
143
144 // Inject the unified GUI bundle and initialize toast system after DOM ready
145 toastScript := s.guiBundle + ";" + themeScript +
146 "(function(){" +
147 "function initToast(){" +
148 "if(window.__dumber_gui && window.__dumber_gui.initializeToast){" +
149 "window.__dumber_gui.initializeToast().then(function(){" +
150 "console.log('✅ Toast system initialized via GUI bundle');" +
151 "}).catch(function(e){console.error('❌ Toast initialization failed:', e);});" +
152 "}else{console.error('❌ GUI bundle not properly loaded');}" +
153 "}" +
154 "if(document.readyState==='loading'){" +
155 "document.addEventListener('DOMContentLoaded',initToast);" +
156 "}else{initToast();}" +
157 "})();"
158
159 return s.webView.InjectScript(toastScript)
160}
161
162// Navigate handles navigation to a URL and records it in history.
163func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) {
164 startTime := time.Now()
165
166 if url == "" {
167 return &NavigationResult{
168 Success: false,
169 Error: "URL cannot be empty",
170 }, nil
171 }
172
173 // Store the initial URL for frontend synchronization
174 if s.initialURL == "" {
175 s.initialURL = url
176 }
177
178 // Record the navigation in history
179 err := s.recordHistory(ctx, url, "")
180 if err != nil {
181 // Don't fail navigation if history recording fails
182 fmt.Printf("Failed to record history: %v\n", err)
183 }
184
185 // Favicon detection is now handled automatically by WebKit's native favicon database
186
187 return &NavigationResult{
188 URL: url,
189 Success: true,
190 LoadTime: time.Since(startTime),
191 }, nil
192}
193
194// UpdatePageTitle updates the title of the current page in history and the window title.
195func (s *BrowserService) UpdatePageTitle(ctx context.Context, url, title string) error {
196 if url == "" || title == "" {
197 return fmt.Errorf("URL and title cannot be empty")
198 }
199
200 // Use AddOrUpdateHistory to update the title
201 titleNull := sql.NullString{String: title, Valid: true}
202 err := s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
203 if err != nil {
204 return err
205 }
206
207 // Update the window title if we have a title updater
208 if s.windowTitleUpdater != nil && title != "" {
209 windowTitle := fmt.Sprintf("Dumber - %s", title)
210 updater := s.windowTitleUpdater
211 if s.webView != nil {
212 s.webView.RunOnMainThread(func() {
213 updater.SetTitle(windowTitle)
214 })
215 } else {
216 updater.SetTitle(windowTitle)
217 }
218 }
219
220 return nil
221}
222
223// GetRecentHistory returns recent browser history entries.
224func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]HistoryEntry, error) {
225 if limit <= 0 {
226 limit = 50 // Default limit
227 }
228
229 entries, err := s.dbQueries.GetHistory(ctx, int64(limit))
230 if err != nil {
231 return nil, err
232 }
233
234 result := make([]HistoryEntry, len(entries))
235 for i, entry := range entries {
236 // Defensive cast to int32 to prevent overflow
237 vc := clampToInt32(entry.VisitCount.Int64)
238 result[i] = HistoryEntry{
239 ID: entry.ID,
240 URL: entry.Url,
241 Title: entry.Title.String,
242 FaviconURL: entry.FaviconUrl.String,
243 VisitCount: vc,
244 LastVisited: entry.LastVisited.Time,
245 CreatedAt: entry.CreatedAt.Time,
246 }
247 }
248
249 return result, nil
250}
251
252// GetRecentHistoryWithOffset returns recent browser history entries with pagination support.
253func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit, offset int) ([]HistoryEntry, error) {
254 if limit <= 0 {
255 limit = 20 // Default limit for pagination
256 }
257 if offset < 0 {
258 offset = 0
259 }
260
261 entries, err := s.dbQueries.GetHistoryWithOffset(ctx, int64(limit), int64(offset))
262 if err != nil {
263 return nil, err
264 }
265
266 result := make([]HistoryEntry, len(entries))
267 for i, entry := range entries {
268 // Defensive cast to int32 to prevent overflow
269 vc := clampToInt32(entry.VisitCount.Int64)
270 result[i] = HistoryEntry{
271 ID: entry.ID,
272 URL: entry.Url,
273 Title: entry.Title.String,
274 FaviconURL: entry.FaviconUrl.String,
275 VisitCount: vc,
276 LastVisited: entry.LastVisited.Time,
277 CreatedAt: entry.CreatedAt.Time,
278 }
279 }
280
281 return result, nil
282}
283
284// SearchHistory searches browser history.
285func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {
286 if query == "" {
287 return s.GetRecentHistory(ctx, limit)
288 }
289
290 if limit <= 0 {
291 limit = 50
292 }
293
294 urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
295 titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
296
297 entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
298 if err != nil {
299 return nil, err
300 }
301
302 result := make([]HistoryEntry, len(entries))
303 for i, entry := range entries {
304 vc := clampToInt32(entry.VisitCount.Int64)
305 result[i] = HistoryEntry{
306 ID: entry.ID,
307 URL: entry.Url,
308 Title: entry.Title.String,
309 FaviconURL: entry.FaviconUrl.String,
310 VisitCount: vc,
311 LastVisited: entry.LastVisited.Time,
312 CreatedAt: entry.CreatedAt.Time,
313 }
314 }
315
316 return result, nil
317}
318
319// DeleteHistoryEntry removes a specific history entry.
320func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
321 return s.dbQueries.DeleteHistory(ctx, id)
322}
323
324// ClearHistory removes all history entries.
325func (s *BrowserService) ClearHistory(ctx context.Context) error {
326 _ = ctx
327 // Note: ClearAllHistory method doesn't exist in current schema
328 // This would need to be implemented in the database layer
329 return fmt.Errorf("clear all history not implemented yet")
330}
331
332// GetHistoryStats returns statistics about browser history.
333func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) {
334 // Get recent entries for basic stats
335 recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit)
336 if err != nil {
337 return nil, err
338 }
339
340 // Calculate basic statistics
341 stats := map[string]interface{}{
342 "total_entries": len(recentEntries),
343 "recent_count": len(recentEntries),
344 }
345
346 // Add more detailed stats if we have entries
347 if len(recentEntries) > 0 {
348 stats["oldest_entry"] = recentEntries[len(recentEntries)-1].CreatedAt.Time
349 stats["newest_entry"] = recentEntries[0].CreatedAt.Time
350 }
351
352 return stats, nil
353}
354
355// GetConfig returns the current browser configuration.
356func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) {
357 _ = ctx
358 return s.config, nil
359}
360
361// UpdateConfig updates the browser configuration.
362func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error {
363 _ = ctx
364 // In a real implementation, you'd want to validate and persist the config
365 s.config = newConfig
366 return nil
367}
368
369// GetSearchShortcuts returns available search shortcuts.
370func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
371 return s.config.SearchShortcuts, nil
372}
373
374// recordHistory adds or updates a history entry.
375func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {
376 // Use AddOrUpdateHistory which handles both cases
377 titleNull := sql.NullString{Valid: false}
378 if title != "" {
379 titleNull = sql.NullString{String: title, Valid: true}
380 }
381
382 return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
383}
384
385// Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%
386var 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}
387
388// ZoomIn increases the zoom level to the next Firefox zoom level.
389func (s *BrowserService) ZoomIn(ctx context.Context, url string) (float64, error) {
390 currentZoom, err := s.GetZoomLevel(ctx, url)
391 if err != nil {
392 return 1.0, err
393 }
394
395 newZoom := s.getNextZoomLevel(currentZoom, true)
396 err = s.SetZoomLevel(ctx, url, newZoom)
397 if err != nil {
398 return currentZoom, err
399 }
400
401 return newZoom, nil
402}
403
404// ZoomOut decreases the zoom level to the previous Firefox zoom level.
405func (s *BrowserService) ZoomOut(ctx context.Context, url string) (float64, error) {
406 currentZoom, err := s.GetZoomLevel(ctx, url)
407 if err != nil {
408 return 1.0, err
409 }
410
411 newZoom := s.getNextZoomLevel(currentZoom, false)
412 err = s.SetZoomLevel(ctx, url, newZoom)
413 if err != nil {
414 return currentZoom, err
415 }
416
417 return newZoom, nil
418}
419
420// ResetZoom resets the zoom level to 1.0 for the current URL.
421func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, error) {
422 return s.setZoom(ctx, url, 1.0)
423}
424
425// GetZoomLevel retrieves the saved zoom level for a URL.
426func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
427 if url == "" {
428 return s.config.DefaultZoom, nil
429 }
430
431 key := zoomKeyFromURL(url)
432 zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key)
433 if err != nil {
434 if errors.Is(err, sql.ErrNoRows) {
435 // Fallback to exact URL record if present (backward compatibility)
436 zl, err2 := s.dbQueries.GetZoomLevel(ctx, url)
437 if err2 == nil {
438 return zl, nil
439 }
440 // No zoom setting found, return configured default
441 return s.config.DefaultZoom, nil
442 }
443 return s.config.DefaultZoom, err
444 }
445
446 return zoomLevel, nil
447}
448
449// SetZoomLevel sets the zoom level for a URL.
450func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {
451 if url == "" {
452 return fmt.Errorf("URL cannot be empty")
453 }
454
455 // Clamp zoom level to reasonable bounds
456 if zoomLevel < zoomMin {
457 zoomLevel = zoomMin
458 } else if zoomLevel > zoomMax {
459 zoomLevel = zoomMax
460 }
461
462 key := zoomKeyFromURL(url)
463 return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel)
464}
465
466// GetCurrentURL returns the current URL (this would be implemented by the frontend)
467func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) {
468 if s.webView == nil {
469 return "", nil
470 }
471 return s.webView.GetCurrentURL(), nil
472}
473
474// GetInitialURL returns the initially navigated URL for frontend synchronization
475func (s *BrowserService) GetInitialURL(ctx context.Context) (string, error) {
476 return s.initialURL, nil
477}
478
479// CopyCurrentURL copies the current URL to clipboard (frontend-initiated)
480func (s *BrowserService) CopyCurrentURL(ctx context.Context, url string) error {
481 // This is primarily for logging/recording the copy action
482 // The actual clipboard operation is handled by the frontend
483 if url != "" {
484 // Could record this action in analytics if needed
485 fmt.Printf("URL copied to clipboard: %s\n", url)
486 }
487 return nil
488}
489
490// GoBack provides navigation back functionality
491func (s *BrowserService) GoBack(ctx context.Context) error {
492 if s.webView != nil {
493 return s.webView.GoBack()
494 }
495 fmt.Println("Navigation: Go back")
496 return nil
497}
498
499// GoForward provides navigation forward functionality
500func (s *BrowserService) GoForward(ctx context.Context) error {
501 if s.webView != nil {
502 return s.webView.GoForward()
503 }
504 fmt.Println("Navigation: Go forward")
505 return nil
506}
507
508// getNextZoomLevel returns the next Firefox zoom level in the specified direction
509const (
510 zoomMin = 0.25
511 zoomMax = 5.0
512 recentHistoryLimit = 1000
513)
514
515func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 {
516 // Find the closest current zoom level
517 closestIndex := 0
518 minDiff := 10.0 // Large initial value
519
520 for i, level := range firefoxZoomLevels {
521 diff := currentZoom - level
522 if diff < 0 {
523 diff = -diff // abs value
524 }
525 if diff < minDiff {
526 minDiff = diff
527 closestIndex = i
528 }
529 }
530
531 if zoomIn {
532 // Move to next higher zoom level
533 if closestIndex < len(firefoxZoomLevels)-1 {
534 return firefoxZoomLevels[closestIndex+1]
535 }
536 return firefoxZoomLevels[closestIndex] // Already at max
537 }
538 // Move to next lower zoom level
539 if closestIndex > 0 {
540 return firefoxZoomLevels[closestIndex-1]
541 }
542 return firefoxZoomLevels[closestIndex] // Already at min
543}
544
545// setZoom sets the zoom level to a specific value
546func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) {
547 err := s.SetZoomLevel(ctx, url, zoomLevel)
548 if err != nil {
549 return 1.0, err
550 }
551 return zoomLevel, nil
552}
553
554// zoomKeyFromURL extracts a stable per-domain key for zoom persistence.
555// Uses hostname if present; falls back to the full input URL string.
556func zoomKeyFromURL(raw string) string {
557 u, err := neturl.Parse(raw)
558 if err != nil || u.Host == "" {
559 return raw
560 }
561 return u.Host
562}
563
564// extractDomain extracts the domain from a URL for zoom level storage
565// Domain-based zoom allows settings to persist across different pages of the same site
566// extractDomain is no longer used; zoom is stored per URL for contract parity.
567
568// ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.
569func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }
570
571// handleFaviconChanged processes favicon data received from WebKit (legacy binary data method)
572func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) {
573 log.Printf("[favicon] Processing favicon for %s, data size: %d bytes", pageURL, len(pngData))
574
575 if len(pngData) == 0 {
576 log.Printf("[favicon] No favicon data for %s", pageURL)
577 return
578 }
579
580 ctx := context.Background()
581
582 // Parse URL for validation
583 parsedURL, err := neturl.Parse(pageURL)
584 if err != nil {
585 return
586 }
587
588 // Skip favicon update for localhost, file://, or special schemes
589 if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
590 return
591 }
592 if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
593 return
594 }
595
596 // Cache the PNG data directly
597 if faviconCache, err := cache.NewFaviconCache(); err == nil {
598 if err := faviconCache.CachePNGData(pageURL, pngData); err != nil {
599 // Log error but don't fail
600 log.Printf("[favicon] Failed to cache favicon for %s: %v", pageURL, err)
601 } else {
602 log.Printf("[favicon] Successfully cached favicon for %s (%d bytes)", pageURL, len(pngData))
603 }
604 } else {
605 log.Printf("[favicon] Failed to create favicon cache: %v", err)
606 }
607
608 // Store a reference to the cached file in the database
609 // We use a consistent path format for the favicon URL
610 faviconCacheURL := fmt.Sprintf("dumb://favicon/%x.png", md5.Sum([]byte(pageURL)))
611 faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true}
612 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
613 // Silently fail - favicon is not critical
614 log.Printf("[browser] Failed to update favicon URL in database for %s: %v", pageURL, err)
615 }
616}
617
618// ProcessFaviconURI is a public wrapper for handling favicon URI changes from any webview
619func (s *BrowserService) ProcessFaviconURI(pageURL string, faviconURI string) {
620 s.handleFaviconURIChanged(pageURL, faviconURI)
621}
622
623// handleFaviconURIChanged processes favicon URI changes from WebKit's native favicon database
624func (s *BrowserService) handleFaviconURIChanged(pageURL string, faviconURI string) {
625 log.Printf("[favicon] Processing favicon URI for %s: %s", pageURL, faviconURI)
626
627 ctx := context.Background()
628
629 // Parse URL for validation
630 parsedURL, err := neturl.Parse(pageURL)
631 if err != nil {
632 log.Printf("[favicon] Invalid page URL: %s", pageURL)
633 return
634 }
635
636 // Skip favicon update for localhost, file://, or special schemes
637 if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
638 return
639 }
640 if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
641 return
642 }
643
644 // Store the favicon URI directly in the database
645 // The frontend will handle SVG vs PNG/ICO detection
646 faviconNullString := sql.NullString{String: faviconURI, Valid: true}
647 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
648 log.Printf("[browser] Failed to update favicon URI in database for %s: %v", pageURL, err)
649 }
650
651 // Download and cache the favicon asynchronously (same approach as dmenu)
652 if faviconCache, err := cache.NewFaviconCache(); err == nil {
653 faviconCache.CacheAsync(faviconURI)
654 log.Printf("[favicon] Started async download of favicon from %s for page %s", faviconURI, pageURL)
655 } else {
656 log.Printf("[favicon] Failed to create favicon cache: %v", err)
657 }
658}
659
660// ColorPalettesResponse holds light and dark palettes for JSON marshaling
661type ColorPalettesResponse struct {
662 Light config.ColorPalette `json:"light"`
663 Dark config.ColorPalette `json:"dark"`
664}
665
666// GetColorPalettesForMessaging returns the color palettes from config
667func (s *BrowserService) GetColorPalettesForMessaging() ColorPalettesResponse {
668 if s.config == nil {
669 return ColorPalettesResponse{}
670 }
671 return ColorPalettesResponse{
672 Light: s.config.Appearance.LightPalette,
673 Dark: s.config.Appearance.DarkPalette,
674 }
675}