1package messaging
2
3import (
4 "context"
5 "encoding/json"
6 "log"
7 "strconv"
8 "time"
9
10 "github.com/bnema/dumber/internal/app/constants"
11 "github.com/bnema/dumber/internal/app/control"
12 "github.com/bnema/dumber/internal/config"
13 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/dumber/internal/services"
15 "github.com/bnema/dumber/pkg/webkit"
16)
17
18// Handler processes script messages from the WebView
19type WorkspaceObserver interface {
20 OnWorkspaceMessage(source *webkit.WebView, msg Message)
21}
22
23type Handler struct {
24 parserService *services.ParserService
25 browserService *services.BrowserService
26 webView *webkit.WebView
27 navigationController *control.NavigationController
28 lastTheme string
29 workspaceObserver WorkspaceObserver
30}
31
32// Message represents a script message from the WebView
33type Message struct {
34 Type string `json:"type"`
35 URL string `json:"url"`
36 Q string `json:"q"`
37 Limit int `json:"limit"`
38 Offset int `json:"offset"`
39 Value string `json:"value"`
40 Event string `json:"event"`
41 Action string `json:"action"`
42 Direction string `json:"direction"`
43 // History operations
44 HistoryID string `json:"historyId"`
45 // Request tracking
46 RequestID string `json:"requestId"`
47 // Popup close tracking
48 WebViewID string `json:"webviewId"`
49 Reason string `json:"reason"`
50 // Wails fetch bridge
51 ID string `json:"id"`
52 Payload json.RawMessage `json:"payload"`
53 // Favorites operations
54 Title string `json:"title"`
55 FaviconURL string `json:"faviconURL"`
56}
57
58// NewHandler creates a new message handler
59func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler {
60 return &Handler{
61 parserService: parserService,
62 browserService: browserService,
63 }
64}
65
66// SetNavigationController injects the navigation controller for unified navigation flow.
67func (h *Handler) SetNavigationController(controller *control.NavigationController) {
68 h.navigationController = controller
69}
70
71// SetWorkspaceObserver registers a workspace event observer.
72func (h *Handler) SetWorkspaceObserver(observer WorkspaceObserver) {
73 h.workspaceObserver = observer
74}
75
76// Handle processes incoming script messages
77func (h *Handler) Handle(payload string) {
78 var msg Message
79 if err := json.Unmarshal([]byte(payload), &msg); err != nil {
80 log.Printf("[ERROR] Failed to unmarshal message: %v", err)
81 return
82 }
83
84 switch msg.Type {
85 case "navigate":
86 h.handleNavigation(msg)
87 case "query":
88 if h.webView == nil {
89 return
90 }
91 h.handleQuery(msg)
92 case "wails":
93 h.handleWailsBridge(msg)
94 case "theme":
95 h.handleTheme(msg)
96 case "history_recent":
97 h.handleHistoryRecent(msg)
98 case "history_stats":
99 h.handleHistoryStats(msg)
100 case "history_search":
101 h.handleHistorySearch(msg)
102 case "history_delete":
103 h.handleHistoryDelete(msg)
104 case "workspace":
105 h.handleWorkspace(msg)
106 case "close-popup":
107 h.handleClosePopup(msg)
108 case "console-message":
109 h.handleConsoleMessage(msg)
110 case "request-webview-id":
111 h.handleWebViewIDRequest(msg)
112 case "get_search_shortcuts":
113 h.handleGetSearchShortcuts(msg)
114 case "get_color_palettes":
115 h.handleGetColorPalettes(msg)
116 case "get_favorites":
117 h.handleGetFavorites(msg)
118 case "toggle_favorite":
119 h.handleToggleFavorite(msg)
120 case "is_favorite":
121 h.handleIsFavorite(msg)
122 }
123}
124
125// SetWebView sets the WebView reference (needed for script injection)
126func (h *Handler) SetWebView(webView *webkit.WebView) {
127 h.webView = webView
128}
129
130// handleNavigation processes navigation requests from the frontend
131func (h *Handler) handleNavigation(msg Message) {
132 if h.navigationController != nil {
133 if err := h.navigationController.NavigateToURL(msg.URL); err != nil {
134 log.Printf("[messaging] Navigation controller failed for input %q: %v", msg.URL, err)
135 h.legacyNavigate(msg)
136 }
137 return
138 }
139
140 h.legacyNavigate(msg)
141}
142
143func (h *Handler) legacyNavigate(msg Message) {
144 ctx := context.Background()
145 res, err := h.parserService.ParseInput(ctx, msg.URL)
146 if err != nil {
147 log.Printf("[messaging] Legacy navigation parse failed for %q: %v", msg.URL, err)
148 return
149 }
150
151 if _, navErr := h.browserService.Navigate(ctx, res.URL); navErr != nil {
152 log.Printf("Warning: failed to navigate to %s: %v", res.URL, navErr)
153 }
154
155 if h.webView == nil {
156 return
157 }
158
159 if err := h.webView.LoadURL(res.URL); err != nil {
160 log.Printf("[messaging] Legacy LoadURL failed for %s: %v", res.URL, err)
161 }
162
163 if z, zerr := h.browserService.GetZoomLevel(ctx, res.URL); zerr == nil {
164 if err := h.webView.SetZoom(z); err != nil {
165 log.Printf("[messaging] Legacy SetZoom failed for %s: %v", res.URL, err)
166 }
167 }
168}
169
170// (legacy query handler removed; omnibox suggestions now fetched via dumb://api/omnibox/suggestions)
171// handleQuery computes omnibox suggestions natively and returns them to the GUI without fetch
172func (h *Handler) handleWorkspace(msg Message) {
173 if h.workspaceObserver == nil {
174 log.Printf("[workspace] Received workspace event %q but no observer registered", msg.Event)
175 return
176 }
177 if h.webView == nil {
178 log.Printf("[workspace] Ignoring workspace event %q: webview not attached", msg.Event)
179 return
180 }
181 log.Printf("[workspace] Forwarding workspace event: event=%s direction=%s action=%s webviewId=%s", msg.Event, msg.Direction, msg.Action, msg.WebViewID)
182 h.workspaceObserver.OnWorkspaceMessage(h.webView, msg)
183}
184
185func (h *Handler) handleClosePopup(msg Message) {
186 log.Printf("[messaging] Received close-popup request: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
187
188 if h.workspaceObserver == nil {
189 log.Printf("[messaging] No workspace observer registered for close-popup request")
190 return
191 }
192 if h.webView == nil {
193 log.Printf("[messaging] No webview attached for close-popup request")
194 return
195 }
196
197 // Forward close-popup request to workspace manager via observer
198 closeMsg := Message{
199 Type: "workspace",
200 Event: "close-popup",
201 WebViewID: msg.WebViewID,
202 Reason: msg.Reason,
203 }
204
205 log.Printf("[messaging] Forwarding close-popup to workspace: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
206 h.workspaceObserver.OnWorkspaceMessage(h.webView, closeMsg)
207}
208
209func (h *Handler) handleQuery(msg Message) {
210 if h.webView == nil || h.browserService == nil {
211 return
212 }
213 ctx := context.Background()
214 q := msg.Q
215 if q == "" {
216 q = ""
217 }
218 limit := msg.Limit
219 if limit <= 0 {
220 limit = 10
221 }
222
223 // Build suggestions: shortcuts first, then history search
224 type suggestion struct {
225 URL string `json:"url"`
226 Favicon string `json:"favicon,omitempty"`
227 }
228
229 results := make([]suggestion, 0, limit)
230 seen := make(map[string]struct{}, limit*2)
231
232 // Shortcuts intentionally omitted; rely on explicit prefix commands (e.g., gh:) or history results
233
234 // History
235 if len(results) < limit {
236 remaining := limit - len(results)
237 if entries, err := h.browserService.SearchHistory(ctx, q, remaining); err == nil {
238 for _, e := range entries {
239 // JSON roundtrip to map to get url field agnostic of struct tag
240 bb, _ := json.Marshal(e)
241 var m map[string]any
242 _ = json.Unmarshal(bb, &m)
243 var url string
244 if s, ok := m["url"].(string); ok {
245 url = s
246 } else if s, ok := m["URL"].(string); ok {
247 url = s
248 }
249 if url == "" {
250 continue
251 }
252 if _, ok := seen[url]; ok {
253 continue
254 }
255 // Use favicon_url from database (populated by FaviconService)
256 favicon := ""
257 if s, ok := m["favicon_url"].(string); ok && s != "" {
258 favicon = s
259 }
260 results = append(results, suggestion{URL: url, Favicon: favicon})
261 seen[url] = struct{}{}
262 if len(results) >= limit {
263 break
264 }
265 }
266 }
267 }
268
269 // Inject back to GUI
270 if b, err := json.Marshal(results); err == nil {
271 // Prefer unified page-world API; fallback to legacy global function
272 script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))"
273 if err := h.webView.InjectScript(script); err != nil {
274 log.Printf("[ERROR] Failed to inject omnibox suggestions: %v", err)
275 }
276 }
277}
278
279// handleWailsBridge processes Wails runtime bridge calls for homepage
280func (h *Handler) handleWailsBridge(msg Message) {
281 // Payload contains { methodID, methodName?, args }
282 var p struct {
283 MethodID uint32 `json:"methodID"`
284 MethodName string `json:"methodName"`
285 Args json.RawMessage `json:"args"`
286 }
287 if err := json.Unmarshal(msg.Payload, &p); err != nil {
288 return
289 }
290
291 // Only implement the IDs we need
292 switch p.MethodID {
293 case constants.HashGetRecentHistory: // BrowserService.GetRecentHistory(limit)
294 var args []interface{}
295 _ = json.Unmarshal(p.Args, &args)
296 limit := 50
297 if len(args) > 0 {
298 if f, ok := args[0].(float64); ok {
299 limit = int(f)
300 }
301 }
302 ctx := context.Background()
303 entries, err := h.browserService.GetRecentHistory(ctx, limit)
304 if err != nil {
305 return
306 }
307 resp, _ := json.Marshal(entries)
308 if h.webView != nil {
309 _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
310 }
311 case constants.HashGetSearchShortcuts: // BrowserService.GetSearchShortcuts()
312 ctx := context.Background()
313 shortcuts, err := h.browserService.GetSearchShortcuts(ctx)
314 if err != nil {
315 return
316 }
317 resp, _ := json.Marshal(shortcuts)
318 if h.webView != nil {
319 _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
320 }
321 default:
322 // Return empty JSON to avoid breaking UI
323 if h.webView != nil {
324 _ = h.webView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', '{}')")
325 }
326 }
327}
328
329// handleTheme processes theme-related messages
330func (h *Handler) handleTheme(msg Message) {
331 if msg.Value != "" && msg.Value != h.lastTheme {
332 log.Printf("[theme] color-scheme changed: %s", msg.Value)
333 h.lastTheme = msg.Value
334 }
335}
336
337// handleHistoryRecent processes recent history requests
338func (h *Handler) handleHistoryRecent(msg Message) {
339 if h.browserService == nil || h.webView == nil {
340 return
341 }
342
343 ctx := context.Background()
344 limit := msg.Limit
345 if limit <= 0 {
346 limit = 20
347 }
348 offset := msg.Offset
349
350 var entries []interface{}
351 var err error
352
353 // Use paginated version if offset is provided
354 if offset > 0 {
355 histEntries, e := h.browserService.GetRecentHistoryWithOffset(ctx, limit, offset)
356 if e != nil {
357 err = e
358 } else {
359 entries = make([]interface{}, len(histEntries))
360 for i, entry := range histEntries {
361 entries[i] = entry
362 }
363 }
364 } else {
365 // Use original method for backward compatibility
366 histEntries, e := h.browserService.GetRecentHistory(ctx, limit)
367 if e != nil {
368 err = e
369 } else {
370 entries = make([]interface{}, len(histEntries))
371 for i, entry := range histEntries {
372 entries[i] = entry
373 }
374 }
375 }
376
377 if err != nil {
378 log.Printf("[messaging] Failed to get recent history: %v", err)
379 requestIdParam := ""
380 if msg.RequestID != "" {
381 requestIdParam = ", '" + msg.RequestID + "'"
382 }
383 _ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to load recent history'" + requestIdParam + ")")
384 return
385 }
386
387 b, _ := json.Marshal(entries)
388 requestIdParam := ""
389 if msg.RequestID != "" {
390 requestIdParam = ", '" + msg.RequestID + "'"
391 }
392 _ = h.webView.InjectScript("window.__dumber_history_recent && window.__dumber_history_recent(" + string(b) + requestIdParam + ")")
393}
394
395// handleHistoryStats processes history stats requests
396func (h *Handler) handleHistoryStats(_ Message) {
397 if h.browserService == nil || h.webView == nil {
398 return
399 }
400
401 ctx := context.Background()
402 stats, err := h.browserService.GetHistoryStats(ctx)
403 if err != nil {
404 log.Printf("[messaging] Failed to get history stats: %v", err)
405 _ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to load history stats')")
406 return
407 }
408
409 b, _ := json.Marshal(stats)
410 _ = h.webView.InjectScript("window.__dumber_history_stats && window.__dumber_history_stats(" + string(b) + ")")
411}
412
413// handleHistorySearch processes history search requests
414func (h *Handler) handleHistorySearch(msg Message) {
415 if h.browserService == nil || h.webView == nil {
416 return
417 }
418
419 ctx := context.Background()
420 query := msg.Q
421 limit := msg.Limit
422 if limit <= 0 {
423 limit = 5
424 }
425
426 entries, err := h.browserService.SearchHistory(ctx, query, limit)
427 if err != nil {
428 log.Printf("[messaging] Failed to search history: %v", err)
429 _ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to search history')")
430 return
431 }
432
433 b, _ := json.Marshal(entries)
434 _ = h.webView.InjectScript("window.__dumber_history_search && window.__dumber_history_search(" + string(b) + ")")
435}
436
437// handleHistoryDelete processes history deletion requests
438func (h *Handler) handleHistoryDelete(msg Message) {
439 if h.browserService == nil || h.webView == nil {
440 return
441 }
442
443 if msg.HistoryID == "" {
444 log.Printf("[messaging] History delete: missing historyId")
445 _ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Missing history ID')")
446 return
447 }
448
449 // Convert string ID to int64
450 id, err := strconv.ParseInt(msg.HistoryID, 10, 64)
451 if err != nil {
452 log.Printf("[messaging] History delete: invalid ID format: %v", err)
453 _ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Invalid history ID format')")
454 return
455 }
456
457 ctx := context.Background()
458 err = h.browserService.DeleteHistoryEntry(ctx, id)
459 if err != nil {
460 log.Printf("[messaging] Failed to delete history entry: %v", err)
461 _ = h.webView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to delete history entry')")
462 return
463 }
464
465 // Send success response with the deleted ID
466 successData := map[string]string{"deletedId": msg.HistoryID}
467 b, _ := json.Marshal(successData)
468 _ = h.webView.InjectScript("window.__dumber_history_deleted && window.__dumber_history_deleted(" + string(b) + ")")
469}
470
471// handleConsoleMessage processes console-message from JavaScript
472func (h *Handler) handleConsoleMessage(msg Message) {
473 // Check if console capture is enabled
474 cfg := config.Get()
475 if !cfg.Logging.CaptureConsole {
476 return
477 }
478
479 // Parse the console message payload
480 var consolePayload struct {
481 Level string `json:"level"`
482 Message string `json:"message"`
483 URL string `json:"url"`
484 }
485
486 if err := json.Unmarshal(msg.Payload, &consolePayload); err != nil {
487 log.Printf("[messaging] Failed to unmarshal console-message: %v", err)
488 return
489 }
490
491 // Send to logging system with [CONSOLE] tag
492 logging.CaptureWebKitLog(consolePayload.Message)
493}
494
495// handleWebViewIDRequest responds to JavaScript requests for the webview ID
496func (h *Handler) handleWebViewIDRequest(msg Message) {
497 if h.webView == nil {
498 log.Printf("[messaging] Cannot provide webview ID - no webview available")
499 return
500 }
501
502 // Check if WebView is destroyed before attempting any operations
503 if h.webView.IsDestroyed() {
504 log.Printf("[messaging] Cannot provide webview ID - webview is destroyed")
505 return
506 }
507
508 webViewID := h.webView.ID()
509 log.Printf("[messaging] Sending webview ID %d to JavaScript", webViewID)
510
511 // Send the webview ID back to JavaScript via custom event
512 if err := h.webView.DispatchCustomEvent("dumber:webview-id", map[string]any{
513 "webviewId": webViewID,
514 "timestamp": time.Now().UnixMilli(),
515 }); err != nil {
516 log.Printf("[messaging] Failed to send webview ID: %v", err)
517 }
518}
519
520// handleGetSearchShortcuts sends search shortcuts configuration to JavaScript
521func (h *Handler) handleGetSearchShortcuts(msg Message) {
522 if h.webView == nil {
523 log.Printf("[messaging] Cannot provide search shortcuts - no webview available")
524 return
525 }
526
527 if h.webView.IsDestroyed() {
528 log.Printf("[messaging] Cannot provide search shortcuts - webview is destroyed")
529 return
530 }
531
532 // Get search shortcuts from browser service config
533 shortcuts, err := h.browserService.GetSearchShortcuts(context.Background())
534 if err != nil {
535 log.Printf("[messaging] Failed to get search shortcuts: %v", err)
536 _ = h.webView.InjectScript("window.__dumber_search_shortcuts_error && window.__dumber_search_shortcuts_error('Failed to get search shortcuts')")
537 return
538 }
539
540 // Marshal to JSON
541 b, err := json.Marshal(shortcuts)
542 if err != nil {
543 log.Printf("[messaging] Failed to marshal search shortcuts: %v", err)
544 _ = h.webView.InjectScript("window.__dumber_search_shortcuts_error && window.__dumber_search_shortcuts_error('Failed to load search shortcuts')")
545 return
546 }
547
548 // Inject the search shortcuts into the page
549 log.Printf("[messaging] Sending search shortcuts to JavaScript")
550 _ = h.webView.InjectScript("window.__dumber_search_shortcuts && window.__dumber_search_shortcuts(" + string(b) + ")")
551}
552
553// handleGetColorPalettes sends color palettes configuration to JavaScript
554func (h *Handler) handleGetColorPalettes(msg Message) {
555 if h.webView == nil {
556 log.Printf("[messaging] Cannot provide color palettes - no webview available")
557 return
558 }
559
560 if h.webView.IsDestroyed() {
561 log.Printf("[messaging] Cannot provide color palettes - webview is destroyed")
562 return
563 }
564
565 // Get color palettes from browser service config
566 palettes := h.browserService.GetColorPalettesForMessaging()
567
568 // Marshal to JSON
569 b, err := json.Marshal(palettes)
570 if err != nil {
571 log.Printf("[messaging] Failed to marshal color palettes: %v", err)
572 _ = h.webView.InjectScript("window.__dumber_color_palettes_error && window.__dumber_color_palettes_error('Failed to load color palettes')")
573 return
574 }
575
576 // Inject the color palettes into the page
577 log.Printf("[messaging] Sending color palettes to JavaScript")
578 _ = h.webView.InjectScript("window.__dumber_color_palettes && window.__dumber_color_palettes(" + string(b) + ")")
579}
580
581// handleGetFavorites sends all favorites to JavaScript
582func (h *Handler) handleGetFavorites(msg Message) {
583 if h.webView == nil {
584 log.Printf("[messaging] Cannot provide favorites - no webview available")
585 return
586 }
587
588 if h.webView.IsDestroyed() {
589 log.Printf("[messaging] Cannot provide favorites - webview is destroyed")
590 return
591 }
592
593 // Get favorites from browser service
594 favorites, err := h.browserService.GetFavorites(context.Background())
595 if err != nil {
596 log.Printf("[messaging] Failed to get favorites: %v", err)
597 _ = h.webView.InjectScript("window.__dumber_favorites_error && window.__dumber_favorites_error('Failed to get favorites')")
598 return
599 }
600
601 // Marshal to JSON
602 b, err := json.Marshal(favorites)
603 if err != nil {
604 log.Printf("[messaging] Failed to marshal favorites: %v", err)
605 _ = h.webView.InjectScript("window.__dumber_favorites_error && window.__dumber_favorites_error('Failed to load favorites')")
606 return
607 }
608
609 // Inject the favorites into the page
610 log.Printf("[messaging] Sending %d favorites to JavaScript", len(favorites))
611 _ = h.webView.InjectScript("window.__dumber_favorites && window.__dumber_favorites(" + string(b) + ")")
612}
613
614// handleToggleFavorite adds or removes a URL from favorites
615func (h *Handler) handleToggleFavorite(msg Message) {
616 if h.webView == nil {
617 log.Printf("[messaging] Cannot toggle favorite - no webview available")
618 return
619 }
620
621 if h.webView.IsDestroyed() {
622 log.Printf("[messaging] Cannot toggle favorite - webview is destroyed")
623 return
624 }
625
626 if msg.URL == "" {
627 log.Printf("[messaging] Cannot toggle favorite - URL is empty")
628 return
629 }
630
631 // Toggle the favorite
632 added, err := h.browserService.ToggleFavorite(context.Background(), msg.URL, msg.Title, msg.FaviconURL)
633 if err != nil {
634 log.Printf("[messaging] Failed to toggle favorite for %s: %v", msg.URL, err)
635 _ = h.webView.InjectScript("window.__dumber_favorite_toggled_error && window.__dumber_favorite_toggled_error('Failed to toggle favorite')")
636 return
637 }
638
639 // Send result back to JavaScript
640 result := map[string]interface{}{
641 "url": msg.URL,
642 "added": added,
643 }
644 b, err := json.Marshal(result)
645 if err != nil {
646 log.Printf("[messaging] Failed to marshal toggle result: %v", err)
647 return
648 }
649
650 log.Printf("[messaging] Favorite toggled for %s (added: %v)", msg.URL, added)
651 _ = h.webView.InjectScript("window.__dumber_favorite_toggled && window.__dumber_favorite_toggled(" + string(b) + ")")
652}
653
654// handleIsFavorite checks if a URL is favorited
655func (h *Handler) handleIsFavorite(msg Message) {
656 if h.webView == nil {
657 log.Printf("[messaging] Cannot check favorite - no webview available")
658 return
659 }
660
661 if h.webView.IsDestroyed() {
662 log.Printf("[messaging] Cannot check favorite - webview is destroyed")
663 return
664 }
665
666 if msg.URL == "" {
667 log.Printf("[messaging] Cannot check favorite - URL is empty")
668 return
669 }
670
671 // Check if the URL is favorited
672 isFavorite, err := h.browserService.IsFavorite(context.Background(), msg.URL)
673 if err != nil {
674 log.Printf("[messaging] Failed to check if favorite for %s: %v", msg.URL, err)
675 return
676 }
677
678 // Send result back to JavaScript
679 result := map[string]interface{}{
680 "url": msg.URL,
681 "isFavorite": isFavorite,
682 }
683 b, err := json.Marshal(result)
684 if err != nil {
685 log.Printf("[messaging] Failed to marshal is_favorite result: %v", err)
686 return
687 }
688
689 _ = h.webView.InjectScript("window.__dumber_is_favorite && window.__dumber_is_favorite(" + string(b) + ")")
690}