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