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.mu.Lock()
72 r.baseCtx = ctx
73 r.mu.Unlock()
74}
75
76func (r *MessageRouter) baseContext() context.Context {
77 if r == nil {
78 return context.Background()
79 }
80 r.mu.RLock()
81 ctx := r.baseCtx
82 r.mu.RUnlock()
83 if ctx == nil {
84 return context.Background()
85 }
86 return ctx
87}
88
89// RegisterHandler registers a handler for a message type.
90func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {
91 if msgType == "" {
92 return errors.New("message type cannot be empty")
93 }
94 if handler == nil {
95 return errors.New("message handler cannot be nil")
96 }
97
98 r.mu.Lock()
99 defer r.mu.Unlock()
100 r.handlers[msgType] = handlerEntry{handler: handler}
101 return nil
102}
103
104// RegisterHandlerWithCallbacks registers a handler and response callbacks.
105// callback is invoked on success, errorCallback (optional) on failure.
106// worldName allows targeting a specific script world (empty for main world).
107func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCallback, worldName string, handler MessageHandler) error {
108 if msgType == "" {
109 return errors.New("message type cannot be empty")
110 }
111 if handler == nil {
112 return errors.New("message handler cannot be nil")
113 }
114 if callback == "" {
115 return errors.New("callback cannot be empty when registering with callbacks")
116 }
117
118 r.mu.Lock()
119 defer r.mu.Unlock()
120 r.handlers[msgType] = handlerEntry{
121 handler: handler,
122 callback: callback,
123 errorCallback: errorCallback,
124 world: worldName,
125 }
126 return nil
127}
128
129// SetupMessageHandler wires the router into the given UserContentManager.
130// It registers the script message handler in the MAIN world (not isolated).
131// WebKit's messageHandlers is only available in main world.
132// The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler.
133func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint, error) {
134 baseCtx := r.baseContext()
135 log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger()
136
137 log.Debug().Msg("SetupMessageHandler called")
138
139 if ucm == nil {
140 log.Warn().Msg("SetupMessageHandler: ucm is nil")
141 return 0, errors.New("user content manager is nil")
142 }
143
144 log.Debug().Msg("SetupMessageHandler: connecting signal with detail")
145
146 // Connect to signal BEFORE registering handler to avoid race conditions
147 // (as recommended by WebKit documentation)
148 cb := func(ucm webkit.UserContentManager, valuePtr uintptr) {
149 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked")
150 r.handleScriptMessage(ucm, valuePtr)
151 }
152
153 r.mu.Lock()
154 r.callbacks = append(r.callbacks, cb) // keep callback alive
155 r.mu.Unlock()
156
157 // Connect to "script-message-received::dumber" with handler name as signal detail
158 signalID := ucm.ConnectScriptMessageReceivedWithDetail(MessageHandlerName, &cb)
159 log.Debug().Uint64("signal_id", uint64(signalID)).Str("handler", MessageHandlerName).Msg("signal connected with detail")
160
161 r.mu.Lock()
162 r.signals = append(r.signals, signalID)
163 r.mu.Unlock()
164
165 log.Debug().Msg("SetupMessageHandler: registering handler in main world")
166
167 // Register handler in main world - webkit.messageHandlers is only available there
168 // nil = main/default world (NULL in C), empty string "" would be a world named ""
169 if ok := ucm.RegisterScriptMessageHandler(MessageHandlerName, nil); !ok {
170 log.Warn().Str("handler", MessageHandlerName).Msg("RegisterScriptMessageHandler returned false")
171 return 0, fmt.Errorf("failed to register script message handler %q in main world", MessageHandlerName)
172 }
173
174 log.Info().
175 Str("handler", MessageHandlerName).
176 Str("world", "main").
177 Uint64("signal_id", uint64(signalID)).
178 Msg("script message handler connected")
179
180 return signalID, nil
181}
182
183// handleScriptMessage decodes the JSC value and routes it to the correct handler.
184func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) {
185 baseCtx := r.baseContext()
186 log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger()
187
188 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry")
189
190 if valuePtr == 0 {
191 log.Warn().Msg("received script message with nil value pointer")
192 return
193 }
194
195 jscValue := javascriptcore.ValueNewFromInternalPtr(valuePtr)
196 if jscValue == nil {
197 log.Warn().Msg("failed to wrap script message JSC value")
198 return
199 }
200
201 rawJSON := jscValue.ToJson(0)
202 log.Debug().Int("json_len", len(rawJSON)).Msg("ToJson result")
203 if rawJSON == "" {
204 // This typically happens when the JS side sends non-serializable values
205 // like Svelte 5 Proxy objects or functions. The frontend should use
206 // JSON.parse(JSON.stringify(obj)) to convert Proxies to plain objects.
207 log.Error().
208 Msg("script message JSON serialization failed (ToJson returned empty) - " +
209 "this usually means JS sent non-serializable values like Proxy objects")
210 return
211 }
212
213 var msg Message
214 if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil {
215 log.Warn().Err(err).Str("json", rawJSON).Msg("failed to unmarshal script message")
216 return
217 }
218
219 senderWV := lookupSenderWebView(senderUCM)
220 if senderWV == nil {
221 log.Warn().Msg("rejecting script message from unknown sender")
222 return
223 }
224 if !isTrustedBridgeWebView(senderWV) {
225 log.Warn().
226 Str("type", msg.Type).
227 Uint64("sender_webview_id", uint64(senderWV.ID())).
228 Str("uri", senderWV.URI()).
229 Msg("rejecting script message from untrusted page")
230 return
231 }
232
233 // Trust the sender WebView resolved from WebKit's UserContentManager, not a
234 // caller-provided webViewId field in page-controlled JSON.
235 msg.WebViewID = uint64(senderWV.ID())
236 r.syncWebViewID(senderWV)
237
238 if msg.Type == "" {
239 log.Warn().Msg("script message missing type")
240 return
241 }
242
243 entry, ok := r.getHandler(msg.Type)
244 if !ok || entry.handler == nil {
245 handlerTypes := r.registeredHandlerTypes()
246 log.Warn().Str("type", msg.Type).Int("registered_handlers", len(handlerTypes)).Msg("no handler registered for message type")
247 for _, handlerType := range handlerTypes {
248 log.Debug().Str("registered_type", handlerType).Msg("available handler")
249 }
250 return
251 }
252
253 log.Info().
254 Str("type", msg.Type).
255 Uint64("webview_id", msg.WebViewID).
256 Int("payload_len", len(msg.Payload)).
257 Str("callback", entry.callback).
258 Msg("received script message, dispatching to handler")
259
260 resp, err := entry.handler.Handle(baseCtx, WebViewID(msg.WebViewID), msg.Payload)
261 log.Debug().Str("type", msg.Type).Err(err).Msg("handler execution completed")
262 if err != nil {
263 log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
264 if entry.errorCallback != "" {
265 if respErr := r.dispatchResponse(baseCtx, WebViewID(msg.WebViewID), entry.errorCallback, entry.world, err.Error()); respErr != nil {
266 log.Warn().Err(respErr).Msg("failed to dispatch error callback")
267 }
268 }
269 return
270 }
271
272 if entry.callback != "" {
273 if err := r.dispatchResponse(baseCtx, WebViewID(msg.WebViewID), entry.callback, entry.world, resp); err != nil {
274 log.Warn().Err(err).
275 Str("callback", entry.callback).
276 Uint64("webview_id", msg.WebViewID).
277 Msg("failed to dispatch callback response")
278 }
279 }
280}
281
282func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {
283 ptr := senderUCM.GoPointer()
284 if ptr == 0 {
285 return nil
286 }
287 return LookupWebViewByUCMPointer(ptr)
288}
289
290func isTrustedBridgeWebView(wv *WebView) bool {
291 if wv == nil {
292 return false
293 }
294 return isTrustedBridgeURI(wv.URI())
295}
296
297func isTrustedBridgeURI(raw string) bool {
298 return isTrustedSystemviewURL(raw)
299}
300
301func (r *MessageRouter) syncWebViewID(wv *WebView) {
302 if wv == nil {
303 return
304 }
305 if !r.markWebViewIDSynced(wv.ID()) {
306 return
307 }
308 script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID()))
309 wv.RunJavaScriptInWorld(r.baseContext(), script, "")
310}
311
312func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool {
313 r.idMu.Lock()
314 defer r.idMu.Unlock()
315 if r.syncedIDs[id] {
316 return false
317 }
318 r.syncedIDs[id] = true
319 return true
320}
321
322// getHandler retrieves a handler by message type.
323func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
324 r.mu.RLock()
325 defer r.mu.RUnlock()
326 entry, ok := r.handlers[msgType]
327 return entry, ok
328}
329
330func (r *MessageRouter) registeredHandlerTypes() []string {
331 r.mu.RLock()
332 defer r.mu.RUnlock()
333 types := make([]string, 0, len(r.handlers))
334 for handlerType := range r.handlers {
335 types = append(types, handlerType)
336 }
337 return types
338}
339
340// dispatchResponse serializes the payload and invokes a window callback in JS.
341func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewID, callback, world string, payload any) error {
342 if ctx == nil {
343 ctx = r.baseContext()
344 }
345
346 if callback == "" {
347 return errors.New("callback name is empty")
348 }
349
350 if webviewID == 0 {
351 return errors.New("webview id is required for JS callback dispatch")
352 }
353
354 wv := LookupWebView(webviewID)
355 if wv == nil {
356 return fmt.Errorf("webview %d not found", webviewID)
357 }
358
359 data, err := json.Marshal(payload)
360 if err != nil {
361 return fmt.Errorf("marshal callback payload: %w", err)
362 }
363
364 script := fmt.Sprintf(
365 `(function(){try{if(window.%[1]s){window.%[1]s(%[2]s);}`+
366 `else{console.warn("dumber callback missing: %[1]s");}}`+
367 `catch(e){console.error("dumber callback %[1]s failed", e);}})();`,
368 callback,
369 string(data),
370 )
371
372 wv.RunJavaScriptInWorld(ctx, script, world)
373 return nil
374}