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