1package cef
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "sync"
9
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/logging"
12)
13
14// Compile-time check: MessageRouter implements port.WebUIHandlerRouter.
15var _ port.WebUIHandlerRouter = (*MessageRouter)(nil)
16
17// Message is the JS -> Go message envelope sent via fetch to /api/message.
18type Message = port.WebUIMessage
19
20type handlerEntry struct {
21 handler portHandlerAdapter
22 callback string
23 errorCallback string
24 world string
25}
26
27// MessageRouter dispatches fetch-based message events to registered handlers.
28type MessageRouter struct {
29 handlers map[string]handlerEntry
30 baseCtx context.Context
31 mu sync.RWMutex
32}
33
34// NewMessageRouter creates a new message router.
35func NewMessageRouter(ctx context.Context) *MessageRouter {
36 if ctx == nil {
37 ctx = context.Background()
38 }
39 return &MessageRouter{
40 handlers: make(map[string]handlerEntry),
41 baseCtx: ctx,
42 }
43}
44
45func (r *MessageRouter) SetBaseContext(ctx context.Context) {
46 if r == nil {
47 return
48 }
49 if ctx == nil {
50 ctx = context.Background()
51 }
52 r.mu.Lock()
53 r.baseCtx = ctx
54 r.mu.Unlock()
55}
56
57// RegisterHandler registers a handler for a message type.
58// Satisfies port.WebUIHandlerRouter.
59func (r *MessageRouter) RegisterHandler(msgType string, handler port.WebUIMessageHandler) error {
60 if msgType == "" {
61 return errors.New("message type cannot be empty")
62 }
63 if handler == nil {
64 return errors.New("message handler cannot be nil")
65 }
66 r.mu.Lock()
67 defer r.mu.Unlock()
68 r.handlers[msgType] = handlerEntry{handler: portHandlerAdapter{handler}}
69 return nil
70}
71
72// RegisterHandlerWithCallbacks registers a handler with response callback names.
73// callback is invoked on success, errorCallback (optional) on failure.
74// worldName allows targeting a specific script world (unused in CEF fetch bridge).
75// Satisfies port.WebUIHandlerRouter.
76func (r *MessageRouter) RegisterHandlerWithCallbacks(
77 msgType, callback, errorCallback, worldName string, handler port.WebUIMessageHandler,
78) error {
79 if msgType == "" {
80 return errors.New("message type cannot be empty")
81 }
82 if handler == nil {
83 return errors.New("message handler cannot be nil")
84 }
85 if callback == "" {
86 return errors.New("callback cannot be empty when registering with callbacks")
87 }
88 r.mu.Lock()
89 defer r.mu.Unlock()
90 r.handlers[msgType] = handlerEntry{
91 handler: portHandlerAdapter{handler},
92 callback: callback,
93 errorCallback: errorCallback,
94 world: worldName,
95 }
96 return nil
97}
98
99// portHandlerAdapter wraps a port.WebUIMessageHandler, converting the WebViewID type.
100type portHandlerAdapter struct {
101 inner port.WebUIMessageHandler
102}
103
104func (a portHandlerAdapter) Handle(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error) {
105 return a.inner.Handle(ctx, port.WebViewID(webviewID), payload)
106}
107
108// HandleMessage decodes a raw JSON message body and routes it to the correct handler.
109// Returns the JSON-encoded response. Called by the scheme handler for /api/message POST.
110func (r *MessageRouter) HandleMessage(ctx context.Context, webviewID uint64, body []byte) ([]byte, error) {
111 r.mu.RLock()
112 baseCtx := r.baseCtx
113 r.mu.RUnlock()
114 log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger()
115
116 var msg Message
117 if err := json.Unmarshal(body, &msg); err != nil {
118 return nil, fmt.Errorf("unmarshal message: %w", err)
119 }
120 if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
121 msg.WebViewID = msg.WebViewIDAlt
122 }
123 if msg.WebViewID == 0 {
124 msg.WebViewID = webviewID
125 }
126 if msg.Type == "" {
127 return nil, errors.New("message missing type")
128 }
129
130 r.mu.RLock()
131 entry, ok := r.handlers[msg.Type]
132 r.mu.RUnlock()
133
134 if !ok || entry.handler.inner == nil {
135 log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")
136 return json.Marshal(map[string]string{"error": "unknown message type: " + msg.Type})
137 }
138
139 log.Debug().
140 Str("type", msg.Type).
141 Uint64("webview_id", msg.WebViewID).
142 Int("payload_len", len(msg.Payload)).
143 Msg("dispatching message to handler")
144
145 if ctx == nil {
146 ctx = baseCtx
147 }
148 resp, err := entry.handler.Handle(ctx, msg.WebViewID, msg.Payload)
149 if err != nil {
150 log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
151 result := map[string]any{"error": err.Error()}
152 if entry.errorCallback != "" {
153 result["_callback"] = entry.errorCallback
154 }
155 return json.Marshal(result)
156 }
157
158 result := map[string]any{"data": resp}
159 if entry.callback != "" {
160 result["_callback"] = entry.callback
161 }
162 return json.Marshal(result)
163}
164
165// MessageBridgeJS is the JavaScript shim injected into dumb:// pages to provide
166// the window.dumber.postMessage() bridge via fetch.
167//
168// The message body is sent via the X-Dumber-Body header (base64-encoded) rather
169// than the fetch body, because CEF's PostData element API (GetElements/GetBytes)
170// requires unexported wrappers in purego-cef. Using a header avoids that limitation
171// while keeping the bridge simple and reliable.
172const MessageBridgeJS = `
173window.dumber = {
174 postMessage: async function(msg) {
175 const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg))));
176 const r = await fetch('/api/message', {
177 method: 'POST',
178 headers: {
179 'Content-Type': 'application/json',
180 'X-Dumber-Body': encoded
181 }
182 });
183 const resp = await r.json();
184 if (resp && resp._callback && window[resp._callback]) {
185 window[resp._callback](resp.data);
186 }
187 return resp;
188 }
189};
190`