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 s.windowTitleUpdater.SetTitle(windowTitle)
219 }
220
221 return nil
222}
223
224// GetRecentHistory returns recent browser history entries.
225func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]HistoryEntry, error) {
226 if limit <= 0 {
227 limit = 50 // Default limit
228 }
229
230 entries, err := s.dbQueries.GetHistory(ctx, int64(limit))
231 if err != nil {
232 return nil, err
233 }
234
235 result := make([]HistoryEntry, len(entries))
236 for i, entry := range entries {
237 // Defensive cast to int32 to prevent overflow
238 vc := clampToInt32(entry.VisitCount.Int64)
239 result[i] = HistoryEntry{
240 ID: entry.ID,
241 URL: entry.Url,
242 Title: entry.Title.String,
243 FaviconURL: entry.FaviconUrl.String,
244 VisitCount: vc,
245 LastVisited: entry.LastVisited.Time,
246 CreatedAt: entry.CreatedAt.Time,
247 }
248 }
249
250 return result, nil
251}
252
253// GetRecentHistoryWithOffset returns recent browser history entries with pagination support.
254func (s *BrowserService) GetRecentHistoryWithOffset(ctx context.Context, limit, offset int) ([]HistoryEntry, error) {
255 if limit <= 0 {
256 limit = 20 // Default limit for pagination
257 }
258 if offset < 0 {
259 offset = 0
260 }
261
262 entries, err := s.dbQueries.GetHistoryWithOffset(ctx, int64(limit), int64(offset))
263 if err != nil {
264 return nil, err
265 }
266
267 result := make([]HistoryEntry, len(entries))
268 for i, entry := range entries {
269 // Defensive cast to int32 to prevent overflow
270 vc := clampToInt32(entry.VisitCount.Int64)
271 result[i] = HistoryEntry{
272 ID: entry.ID,
273 URL: entry.Url,
274 Title: entry.Title.String,
275 FaviconURL: entry.FaviconUrl.String,
276 VisitCount: vc,
277 LastVisited: entry.LastVisited.Time,
278 CreatedAt: entry.CreatedAt.Time,
279 }
280 }
281
282 return result, nil
283}
284
285// SearchHistory searches browser history.
286func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {
287 if query == "" {
288 return s.GetRecentHistory(ctx, limit)
289 }
290
291 if limit <= 0 {
292 limit = 50
293 }
294
295 urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
296 titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
297
298 entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
299 if err != nil {
300 return nil, err
301 }
302
303 result := make([]HistoryEntry, len(entries))
304 for i, entry := range entries {
305 vc := clampToInt32(entry.VisitCount.Int64)
306 result[i] = HistoryEntry{
307 ID: entry.ID,
308 URL: entry.Url,
309 Title: entry.Title.String,
310 FaviconURL: entry.FaviconUrl.String,
311 VisitCount: vc,
312 LastVisited: entry.LastVisited.Time,
313 CreatedAt: entry.CreatedAt.Time,
314 }
315 }
316
317 return result, nil
318}
319
320// DeleteHistoryEntry removes a specific history entry.
321func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
322 return s.dbQueries.DeleteHistory(ctx, id)
323}
324
325// ClearHistory removes all history entries.
326func (s *BrowserService) ClearHistory(ctx context.Context) error {
327 _ = ctx
328 // Note: ClearAllHistory method doesn't exist in current schema
329 // This would need to be implemented in the database layer
330 return fmt.Errorf("clear all history not implemented yet")
331}
332
333// GetHistoryStats returns statistics about browser history.
334func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) {
335 // Get recent entries for basic stats
336 recentEntries, err := s.dbQueries.GetHistory(ctx, recentHistoryLimit)
337 if err != nil {
338 return nil, err
339 }
340
341 // Calculate basic statistics
342 stats := map[string]interface{}{
343 "total_entries": len(recentEntries),
344 "recent_count": len(recentEntries),
345 }
346
347 // Add more detailed stats if we have entries
348 if len(recentEntries) > 0 {
349 stats["oldest_entry"] = recentEntries[len(recentEntries)-1].CreatedAt.Time
350 stats["newest_entry"] = recentEntries[0].CreatedAt.Time
351 }
352
353 return stats, nil
354}
355
356// GetConfig returns the current browser configuration.
357func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) {
358 _ = ctx
359 return s.config, nil
360}
361
362// UpdateConfig updates the browser configuration.
363func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error {
364 _ = ctx
365 // In a real implementation, you'd want to validate and persist the config
366 s.config = newConfig
367 return nil
368}
369
370// GetSearchShortcuts returns available search shortcuts.
371func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
372 return s.config.SearchShortcuts, nil
373}
374
375// recordHistory adds or updates a history entry.
376func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {
377 // Use AddOrUpdateHistory which handles both cases
378 titleNull := sql.NullString{Valid: false}
379 if title != "" {
380 titleNull = sql.NullString{String: title, Valid: true}
381 }
382
383 return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
384}
385
386// Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%
387var 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}
388
389// ZoomIn increases the zoom level to the next Firefox zoom level.
390func (s *BrowserService) ZoomIn(ctx context.Context, url string) (float64, error) {
391 currentZoom, err := s.GetZoomLevel(ctx, url)
392 if err != nil {
393 return 1.0, err
394 }
395
396 newZoom := s.getNextZoomLevel(currentZoom, true)
397 err = s.SetZoomLevel(ctx, url, newZoom)
398 if err != nil {
399 return currentZoom, err
400 }
401
402 return newZoom, nil
403}
404
405// ZoomOut decreases the zoom level to the previous Firefox zoom level.
406func (s *BrowserService) ZoomOut(ctx context.Context, url string) (float64, error) {
407 currentZoom, err := s.GetZoomLevel(ctx, url)
408 if err != nil {
409 return 1.0, err
410 }
411
412 newZoom := s.getNextZoomLevel(currentZoom, false)
413 err = s.SetZoomLevel(ctx, url, newZoom)
414 if err != nil {
415 return currentZoom, err
416 }
417
418 return newZoom, nil
419}
420
421// ResetZoom resets the zoom level to 1.0 for the current URL.
422func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, error) {
423 return s.setZoom(ctx, url, 1.0)
424}
425
426// GetZoomLevel retrieves the saved zoom level for a URL.
427func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
428 if url == "" {
429 return 1.0, nil
430 }
431
432 key := zoomKeyFromURL(url)
433 zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key)
434 if err != nil {
435 if errors.Is(err, sql.ErrNoRows) {
436 // Fallback to exact URL record if present (backward compatibility)
437 zl, err2 := s.dbQueries.GetZoomLevel(ctx, url)
438 if err2 == nil {
439 return zl, nil
440 }
441 // No zoom setting found, return default
442 return 1.0, nil
443 }
444 return 1.0, err
445 }
446
447 return zoomLevel, nil
448}
449
450// SetZoomLevel sets the zoom level for a URL.
451func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {
452 if url == "" {
453 return fmt.Errorf("URL cannot be empty")
454 }
455
456 // Clamp zoom level to reasonable bounds
457 if zoomLevel < zoomMin {
458 zoomLevel = zoomMin
459 } else if zoomLevel > zoomMax {
460 zoomLevel = zoomMax
461 }
462
463 key := zoomKeyFromURL(url)
464 return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel)
465}
466
467// GetCurrentURL returns the current URL (this would be implemented by the frontend)
468func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) {
469 if s.webView == nil {
470 return "", nil
471 }
472 return s.webView.GetCurrentURL(), nil
473}
474
475// GetInitialURL returns the initially navigated URL for frontend synchronization
476func (s *BrowserService) GetInitialURL(ctx context.Context) (string, error) {
477 return s.initialURL, nil
478}
479
480// CopyCurrentURL copies the current URL to clipboard (frontend-initiated)
481func (s *BrowserService) CopyCurrentURL(ctx context.Context, url string) error {
482 // This is primarily for logging/recording the copy action
483 // The actual clipboard operation is handled by the frontend
484 if url != "" {
485 // Could record this action in analytics if needed
486 fmt.Printf("URL copied to clipboard: %s\n", url)
487 }
488 return nil
489}
490
491// GoBack provides navigation back functionality
492func (s *BrowserService) GoBack(ctx context.Context) error {
493 if s.webView != nil {
494 return s.webView.GoBack()
495 }
496 fmt.Println("Navigation: Go back")
497 return nil
498}
499
500// GoForward provides navigation forward functionality
501func (s *BrowserService) GoForward(ctx context.Context) error {
502 if s.webView != nil {
503 return s.webView.GoForward()
504 }
505 fmt.Println("Navigation: Go forward")
506 return nil
507}
508
509// getNextZoomLevel returns the next Firefox zoom level in the specified direction
510const (
511 zoomMin = 0.25
512 zoomMax = 5.0
513 recentHistoryLimit = 1000
514)
515
516func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 {
517 // Find the closest current zoom level
518 closestIndex := 0
519 minDiff := 10.0 // Large initial value
520
521 for i, level := range firefoxZoomLevels {
522 diff := currentZoom - level
523 if diff < 0 {
524 diff = -diff // abs value
525 }
526 if diff < minDiff {
527 minDiff = diff
528 closestIndex = i
529 }
530 }
531
532 if zoomIn {
533 // Move to next higher zoom level
534 if closestIndex < len(firefoxZoomLevels)-1 {
535 return firefoxZoomLevels[closestIndex+1]
536 }
537 return firefoxZoomLevels[closestIndex] // Already at max
538 }
539 // Move to next lower zoom level
540 if closestIndex > 0 {
541 return firefoxZoomLevels[closestIndex-1]
542 }
543 return firefoxZoomLevels[closestIndex] // Already at min
544}
545
546// setZoom sets the zoom level to a specific value
547func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) {
548 err := s.SetZoomLevel(ctx, url, zoomLevel)
549 if err != nil {
550 return 1.0, err
551 }
552 return zoomLevel, nil
553}
554
555// zoomKeyFromURL extracts a stable per-domain key for zoom persistence.
556// Uses hostname if present; falls back to the full input URL string.
557func zoomKeyFromURL(raw string) string {
558 u, err := neturl.Parse(raw)
559 if err != nil || u.Host == "" {
560 return raw
561 }
562 return u.Host
563}
564
565// extractDomain extracts the domain from a URL for zoom level storage
566// Domain-based zoom allows settings to persist across different pages of the same site
567// extractDomain is no longer used; zoom is stored per URL for contract parity.
568
569// ZoomKeyForLog exposes the derived zoom key (host or raw URL) for logging from other packages.
570func ZoomKeyForLog(raw string) string { return zoomKeyFromURL(raw) }
571
572// updateFavicon fetches and stores favicon URL for a given page URL
573func (s *BrowserService) updateFavicon(ctx context.Context, pageURL string) {
574 parsedURL, err := neturl.Parse(pageURL)
575 if err != nil {
576 return // Silently fail for invalid URLs
577 }
578
579 // Skip favicon update for localhost, file://, or special schemes
580 if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
581 return
582 }
583 if parsedURL.Host == "localhost" || parsedURL.Host == "127.0.0.1" {
584 return
585 }
586
587 // Standard favicon location
588 faviconURL := fmt.Sprintf("%s://%s/favicon.ico", parsedURL.Scheme, parsedURL.Host)
589
590 // Update in database
591 faviconNullString := sql.NullString{String: faviconURL, Valid: true}
592 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
593 // Silently fail - favicon is not critical
594 return
595 }
596
597 // Also cache the favicon for dmenu use
598 if faviconCache, err := cache.NewFaviconCache(); err == nil {
599 faviconCache.CacheAsync(faviconURL)
600 }
601}