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