1package webkit
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 "github.com/bnema/puregotk/v4/javascriptcore"
13 "github.com/bnema/puregotk/v4/webkit"
14)
15
16// Message is the JS -> Go message envelope sent via postMessage.
17type Message = port.WebUIMessage
18
19// MessageHandler is an alias for port.WebUIMessageHandler.
20// Kept for backward compatibility within the webkit package.
21type MessageHandler = port.WebUIMessageHandler
22
23// MessageHandlerFunc is an alias for port.WebUIMessageHandlerFunc.
24// Kept for backward compatibility within the webkit package.
25type MessageHandlerFunc = port.WebUIMessageHandlerFunc
26
27type handlerEntry struct {
28 handler MessageHandler
29 callback string
30 errorCallback string
31 world string
32}
33
34// Compile-time check that MessageRouter implements port.WebUIHandlerRouter.
35var _ port.WebUIHandlerRouter = (*MessageRouter)(nil)
36
37// MessageRouter dispatches script-message events to registered handlers.
38type MessageRouter struct {
39 handlers map[string]handlerEntry
40 baseCtx context.Context
41
42 mu sync.RWMutex
43 callbacks []interface{}
44 signals []uint
45
46 idMu sync.Mutex
47 syncedIDs map[WebViewID]bool
48}
49
50// NewMessageRouter creates a new message router.
51func NewMessageRouter(ctx context.Context) *MessageRouter {
52 if ctx == nil {
53 ctx = context.Background()
54 }
55
56 return &MessageRouter{
57 handlers: make(map[string]handlerEntry),
58 baseCtx: ctx,
59 syncedIDs: make(map[WebViewID]bool),
60 }
61}
62
63// SetBaseContext updates the base context used for logging and handler execution.
64func (r *MessageRouter) SetBaseContext(ctx context.Context) {
65 if r == nil {
66 return
67 }
68 if ctx == nil {
69 ctx = context.Background()
70 }
71 r.baseCtx = ctx
72}
73
74// RegisterHandler registers a handler for a message type.
75func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {
76 if msgType == "" {
77 return errors.New("message type cannot be empty")
78 }
79 if handler == nil {
80 return errors.New("message handler cannot be nil")
81 }
82
83 r.mu.Lock()
84 defer r.mu.Unlock()
85 r.handlers[msgType] = handlerEntry{handler: handler}
86 return nil
87}
88
89// RegisterHandlerWithCallbacks registers a handler and response callbacks.
90// callback is invoked on success, errorCallback (optional) on failure.
91// worldName allows targeting a specific script world (empty for main world).
92func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCallback, worldName string, handler MessageHandler) error {
93 if msgType == "" {
94 return errors.New("message type cannot be empty")
95 }
96 if handler == nil {
97 return errors.New("message handler cannot be nil")
98 }
99 if callback == "" {
100 return errors.New("callback cannot be empty when registering with callbacks")
101 }
102
103 r.mu.Lock()
104 defer r.mu.Unlock()
105 r.handlers[msgType] = handlerEntry{
106 handler: handler,
107 callback: callback,
108 errorCallback: errorCallback,
109 world: worldName,
110 }
111 return nil
112}
113
114// SetupMessageHandler wires the router into the given UserContentManager.
115// It registers the script message handler in the MAIN world (not isolated).
116// WebKit's messageHandlers is only available in main world.
117// The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler.
118func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint, error) {
119 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
120
121 log.Debug().Msg("SetupMessageHandler called")
122
123 if ucm == nil {
124 log.Warn().Msg("SetupMessageHandler: ucm is nil")
125 return 0, errors.New("user content manager is nil")
126 }
127
128 log.Debug().Msg("SetupMessageHandler: connecting signal with detail")
129
130 // Connect to signal BEFORE registering handler to avoid race conditions
131 // (as recommended by WebKit documentation)
132 cb := func(ucm webkit.UserContentManager, valuePtr uintptr) {
133 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked")
134 r.handleScriptMessage(ucm, valuePtr)
135 }
136
137 r.mu.Lock()
138 r.callbacks = append(r.callbacks, cb) // keep callback alive
139 r.mu.Unlock()
140
141 // Connect to "script-message-received::dumber" with handler name as signal detail
142 signalID := ucm.ConnectScriptMessageReceivedWithDetail(MessageHandlerName, &cb)
143 log.Debug().Uint64("signal_id", uint64(signalID)).Str("handler", MessageHandlerName).Msg("signal connected with detail")
144
145 r.mu.Lock()
146 r.signals = append(r.signals, signalID)
147 r.mu.Unlock()
148
149 log.Debug().Msg("SetupMessageHandler: registering handler in main world")
150
151 // Register handler in main world - webkit.messageHandlers is only available there
152 // nil = main/default world (NULL in C), empty string "" would be a world named ""
153 if ok := ucm.RegisterScriptMessageHandler(MessageHandlerName, nil); !ok {
154 log.Warn().Str("handler", MessageHandlerName).Msg("RegisterScriptMessageHandler returned false")
155 return 0, fmt.Errorf("failed to register script message handler %q in main world", MessageHandlerName)
156 }
157
158 log.Info().
159 Str("handler", MessageHandlerName).
160 Str("world", "main").
161 Uint64("signal_id", uint64(signalID)).
162 Msg("script message handler connected")
163
164 return signalID, nil
165}
166
167// handleScriptMessage decodes the JSC value and routes it to the correct handler.
168func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) {
169 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
170
171 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry")
172
173 if valuePtr == 0 {
174 log.Warn().Msg("received script message with nil value pointer")
175 return
176 }
177
178 jscValue := javascriptcore.ValueNewFromInternalPtr(valuePtr)
179 if jscValue == nil {
180 log.Warn().Msg("failed to wrap script message JSC value")
181 return
182 }
183
184 rawJSON := jscValue.ToJson(0)
185 log.Debug().Int("json_len", len(rawJSON)).Msg("ToJson result")
186 if rawJSON == "" {
187 // This typically happens when the JS side sends non-serializable values
188 // like Svelte 5 Proxy objects or functions. The frontend should use
189 // JSON.parse(JSON.stringify(obj)) to convert Proxies to plain objects.
190 log.Error().
191 Msg("script message JSON serialization failed (ToJson returned empty) - " +
192 "this usually means JS sent non-serializable values like Proxy objects")
193 return
194 }
195
196 var msg Message
197 if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil {
198 log.Warn().Err(err).Str("json", rawJSON).Msg("failed to unmarshal script message")
199 return
200 }
201 if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
202 msg.WebViewID = msg.WebViewIDAlt
203 }
204 if msg.WebViewID == 0 {
205 if senderWV := lookupSenderWebView(senderUCM); senderWV != nil {
206 msg.WebViewID = uint64(senderWV.ID())
207 r.syncWebViewID(senderWV)
208 }
209 }
210
211 if msg.Type == "" {
212 log.Warn().Msg("script message missing type")
213 return
214 }
215
216 entry, ok := r.getHandler(msg.Type)
217 if !ok || entry.handler == nil {
218 log.Warn().Str("type", msg.Type).Int("registered_handlers", len(r.handlers)).Msg("no handler registered for message type")
219 // Debug: list all registered handlers
220 r.mu.RLock()
221 for handlerType := range r.handlers {
222 log.Debug().Str("registered_type", handlerType).Msg("available handler")
223 }
224 r.mu.RUnlock()
225 return
226 }
227
228 log.Info().
229 Str("type", msg.Type).
230 Uint64("webview_id", msg.WebViewID).
231 Int("payload_len", len(msg.Payload)).
232 Str("callback", entry.callback).
233 Msg("received script message, dispatching to handler")
234
235 resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload)
236 log.Debug().Str("type", msg.Type).Err(err).Msg("handler execution completed")
237 if err != nil {
238 log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
239 if entry.errorCallback != "" {
240 if respErr := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.errorCallback, entry.world, err.Error()); respErr != nil {
241 log.Warn().Err(respErr).Msg("failed to dispatch error callback")
242 }
243 }
244 return
245 }
246
247 if entry.callback != "" {
248 if err := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.callback, entry.world, resp); err != nil {
249 log.Warn().Err(err).
250 Str("callback", entry.callback).
251 Uint64("webview_id", msg.WebViewID).
252 Msg("failed to dispatch callback response")
253 }
254 }
255}
256
257func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {
258 ptr := senderUCM.GoPointer()
259 if ptr == 0 {
260 return nil
261 }
262 return LookupWebViewByUCMPointer(ptr)
263}
264
265func (r *MessageRouter) syncWebViewID(wv *WebView) {
266 if wv == nil {
267 return
268 }
269 if !r.markWebViewIDSynced(wv.ID()) {
270 return
271 }
272 script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID()))
273 wv.RunJavaScriptInWorld(r.baseCtx, script, "")
274}
275
276func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool {
277 r.idMu.Lock()
278 defer r.idMu.Unlock()
279 if r.syncedIDs[id] {
280 return false
281 }
282 r.syncedIDs[id] = true
283 return true
284}
285
286// getHandler retrieves a handler by message type.
287func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
288 r.mu.RLock()
289 defer r.mu.RUnlock()
290 entry, ok := r.handlers[msgType]
291 return entry, ok
292}
293
294// dispatchResponse serializes the payload and invokes a window callback in JS.
295func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewID, callback, world string, payload any) error {
296 if ctx == nil {
297 ctx = r.baseCtx
298 }
299
300 if callback == "" {
301 return errors.New("callback name is empty")
302 }
303
304 if webviewID == 0 {
305 return errors.New("webview id is required for JS callback dispatch")
306 }
307
308 wv := LookupWebView(webviewID)
309 if wv == nil {
310 return fmt.Errorf("webview %d not found", webviewID)
311 }
312
313 data, err := json.Marshal(payload)
314 if err != nil {
315 return fmt.Errorf("marshal callback payload: %w", err)
316 }
317
318 script := fmt.Sprintf(
319 `(function(){try{if(window.%[1]s){window.%[1]s(%[2]s);}`+
320 `else{console.warn("dumber callback missing: %[1]s");}}`+
321 `catch(e){console.error("dumber callback %[1]s failed", e);}})();`,
322 callback,
323 string(data),
324 )
325
326 wv.RunJavaScriptInWorld(ctx, script, world)
327 return nil
328}