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