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