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