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