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