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