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