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 "github.com/rs/zerolog"
15)
16
17// Message is the JS -> Go message envelope sent via postMessage.
18type Message = port.WebUIMessage
19
20// MessageHandler is an alias for port.WebUIMessageHandler.
21// Kept for backward compatibility within the webkit package.
22type MessageHandler = port.WebUIMessageHandler
23
24// MessageHandlerFunc is an alias for port.WebUIMessageHandlerFunc.
25// Kept for backward compatibility within the webkit package.
26type MessageHandlerFunc = port.WebUIMessageHandlerFunc
27
28type handlerEntry struct {
29 handler MessageHandler
30 callback string
31 errorCallback string
32 world string
33}
34
35// Compile-time check that MessageRouter implements port.WebUIHandlerRouter.
36var _ port.WebUIHandlerRouter = (*MessageRouter)(nil)
37
38// MessageRouter dispatches script-message events to registered handlers.
39type MessageRouter struct {
40 handlers map[string]handlerEntry
41 baseCtx context.Context
42
43 mu sync.RWMutex
44 callbacks []any
45 signals []uint
46
47 idMu sync.Mutex
48 syncedIDs map[WebViewID]bool
49}
50
51// NewMessageRouter creates a new message router.
52func NewMessageRouter(ctx context.Context) *MessageRouter {
53 if ctx == nil {
54 ctx = context.Background()
55 }
56
57 return &MessageRouter{
58 handlers: make(map[string]handlerEntry),
59 baseCtx: ctx,
60 syncedIDs: make(map[WebViewID]bool),
61 }
62}
63
64// SetBaseContext updates the base context used for logging and handler execution.
65func (r *MessageRouter) SetBaseContext(ctx context.Context) {
66 if r == nil {
67 return
68 }
69 if ctx == nil {
70 ctx = context.Background()
71 }
72 r.mu.Lock()
73 r.baseCtx = ctx
74 r.mu.Unlock()
75}
76
77func (r *MessageRouter) baseContext() context.Context {
78 if r == nil {
79 return context.Background()
80 }
81 r.mu.RLock()
82 ctx := r.baseCtx
83 r.mu.RUnlock()
84 if ctx == nil {
85 return context.Background()
86 }
87 return ctx
88}
89
90// RegisterHandler registers a handler for a message type.
91func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {
92 if msgType == "" {
93 return errors.New("message type cannot be empty")
94 }
95 if handler == nil {
96 return errors.New("message handler cannot be nil")
97 }
98
99 r.mu.Lock()
100 defer r.mu.Unlock()
101 r.handlers[msgType] = handlerEntry{handler: handler}
102 return nil
103}
104
105// RegisterHandlerWithCallbacks registers a handler and response callbacks.
106// callback is invoked on success, errorCallback (optional) on failure.
107// worldName allows targeting a specific script world (empty for main world).
108func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCallback, worldName string, handler MessageHandler) error {
109 if msgType == "" {
110 return errors.New("message type cannot be empty")
111 }
112 if handler == nil {
113 return errors.New("message handler cannot be nil")
114 }
115 if callback == "" {
116 return errors.New("callback cannot be empty when registering with callbacks")
117 }
118
119 r.mu.Lock()
120 defer r.mu.Unlock()
121 r.handlers[msgType] = handlerEntry{
122 handler: handler,
123 callback: callback,
124 errorCallback: errorCallback,
125 world: worldName,
126 }
127 return nil
128}
129
130// SetupMessageHandler wires the router into the given UserContentManager.
131// It registers the script message handler in the MAIN world (not isolated).
132// WebKit's messageHandlers is only available in main world.
133// The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler.
134func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint, error) {
135 baseCtx := r.baseContext()
136 log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger()
137
138 log.Debug().Msg("SetupMessageHandler called")
139
140 if ucm == nil {
141 log.Warn().Msg("SetupMessageHandler: ucm is nil")
142 return 0, errors.New("user content manager is nil")
143 }
144
145 log.Debug().Msg("SetupMessageHandler: connecting signal with detail")
146
147 // Connect to signal BEFORE registering handler to avoid race conditions
148 // (as recommended by WebKit documentation)
149 cb := func(ucm webkit.UserContentManager, valuePtr uintptr) {
150 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked")
151 r.handleScriptMessage(ucm, valuePtr)
152 }
153
154 r.mu.Lock()
155 r.callbacks = append(r.callbacks, cb) // keep callback alive
156 r.mu.Unlock()
157
158 // Connect to "script-message-received::dumber" with handler name as signal detail
159 signalID := ucm.ConnectScriptMessageReceivedWithDetail(MessageHandlerName, &cb)
160 log.Debug().Uint64("signal_id", uint64(signalID)).Str("handler", MessageHandlerName).Msg("signal connected with detail")
161
162 r.mu.Lock()
163 r.signals = append(r.signals, signalID)
164 r.mu.Unlock()
165
166 log.Debug().Msg("SetupMessageHandler: registering handler in main world")
167
168 // Register handler in main world - webkit.messageHandlers is only available there
169 // nil = main/default world (NULL in C), empty string "" would be a world named ""
170 if ok := ucm.RegisterScriptMessageHandler(MessageHandlerName, nil); !ok {
171 log.Warn().Str("handler", MessageHandlerName).Msg("RegisterScriptMessageHandler returned false")
172 return 0, fmt.Errorf("failed to register script message handler %q in main world", MessageHandlerName)
173 }
174
175 log.Info().
176 Str("handler", MessageHandlerName).
177 Str("world", "main").
178 Uint64("signal_id", uint64(signalID)).
179 Msg("script message handler connected")
180
181 return signalID, nil
182}
183
184// handleScriptMessage decodes the JSC value and routes it to the correct handler.
185func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) {
186 baseCtx := r.baseContext()
187 log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger()
188
189 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry")
190
191 msg, ok := decodeScriptMessage(valuePtr, log)
192 if !ok {
193 return
194 }
195
196 senderWV := lookupSenderWebView(senderUCM)
197 if senderWV == nil {
198 log.Warn().Msg("rejecting script message from unknown sender")
199 return
200 }
201 if r.handleAllowlistedBridgeMessage(senderWV, msg) {
202 return
203 }
204 if !isTrustedBridgeWebView(senderWV) {
205 log.Warn().
206 Str("type", msg.Type).
207 Uint64("sender_webview_id", uint64(senderWV.ID())).
208 Str("uri", senderWV.URI()).
209 Msg("rejecting script message from untrusted page")
210 return
211 }
212
213 // Trust the sender WebView resolved from WebKit's UserContentManager, not a
214 // caller-provided webViewId field in page-controlled JSON.
215 msg.WebViewID = uint64(senderWV.ID())
216 r.syncWebViewID(senderWV)
217
218 if msg.Type == "" {
219 log.Warn().Msg("script message missing type")
220 return
221 }
222
223 entry, ok := r.getHandler(msg.Type)
224 if !ok || entry.handler == nil {
225 handlerTypes := r.registeredHandlerTypes()
226 log.Warn().Str("type", msg.Type).Int("registered_handlers", len(handlerTypes)).Msg("no handler registered for message type")
227 for _, handlerType := range handlerTypes {
228 log.Debug().Str("registered_type", handlerType).Msg("available handler")
229 }
230 return
231 }
232
233 log.Info().
234 Str("type", msg.Type).
235 Uint64("webview_id", msg.WebViewID).
236 Int("payload_len", len(msg.Payload)).
237 Str("callback", entry.callback).
238 Msg("received script message, dispatching to handler")
239
240 resp, err := entry.handler.Handle(baseCtx, WebViewID(msg.WebViewID), msg.Payload)
241 log.Debug().Str("type", msg.Type).Err(err).Msg("handler execution completed")
242 if err != nil {
243 log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
244 if entry.errorCallback != "" {
245 if respErr := r.dispatchResponse(baseCtx, WebViewID(msg.WebViewID), entry.errorCallback, entry.world, err.Error()); respErr != nil {
246 log.Warn().Err(respErr).Msg("failed to dispatch error callback")
247 }
248 }
249 return
250 }
251
252 if entry.callback != "" {
253 if err := r.dispatchResponse(baseCtx, WebViewID(msg.WebViewID), entry.callback, entry.world, resp); err != nil {
254 log.Warn().Err(err).
255 Str("callback", entry.callback).
256 Uint64("webview_id", msg.WebViewID).
257 Msg("failed to dispatch callback response")
258 }
259 }
260}
261
262func decodeScriptMessage(valuePtr uintptr, log zerolog.Logger) (Message, bool) {
263 if valuePtr == 0 {
264 log.Warn().Msg("received script message with nil value pointer")
265 return Message{}, false
266 }
267
268 jscValue := javascriptcore.ValueNewFromInternalPtr(valuePtr)
269 if jscValue == nil {
270 log.Warn().Msg("failed to wrap script message JSC value")
271 return Message{}, false
272 }
273
274 rawJSON := jscValue.ToJson(0)
275 log.Debug().Int("json_len", len(rawJSON)).Msg("ToJson result")
276 if rawJSON == "" {
277 log.Error().
278 Msg("script message JSON serialization failed (ToJson returned empty) - " +
279 "this usually means JS sent non-serializable values like Proxy objects")
280 return Message{}, false
281 }
282
283 var msg Message
284 if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil {
285 log.Warn().Err(err).Int("json_len", len(rawJSON)).Msg("failed to unmarshal script message")
286 return Message{}, false
287 }
288 return msg, true
289}
290
291// handleAllowlistedBridgeMessage handles the one narrow page-origin hint that
292// is allowed before trusted-page routing: editable focus state for Vim mode.
293// This is intentionally not a privileged bridge command; the only effect is
294// updating editable-focus state on the sender WebView so Vim mode can block or
295// exit locally. The per-WebView token is a reuse/staleness guard, not a broad
296// authorization boundary for page privileges.
297func (r *MessageRouter) handleAllowlistedBridgeMessage(senderWV *WebView, msg Message) bool {
298 if msg.Type != "editable_focus_changed" {
299 return false
300 }
301 log := logging.FromContext(r.baseContext())
302 if senderWV == nil {
303 log.Warn().Str("type", msg.Type).Msg("dropping allowlisted bridge message without sender WebView")
304 return true
305 }
306 var payload struct {
307 Editable bool `json:"editable"`
308 Token string `json:"token"`
309 }
310 if len(msg.Payload) != 0 {
311 if err := json.Unmarshal(msg.Payload, &payload); err != nil {
312 log.Warn().Err(err).Str("type", msg.Type).Msg("failed to decode allowlisted bridge payload")
313 return true
314 }
315 }
316 if payload.Token == "" || !senderWV.matchesEditableFocusBridgeToken(payload.Token) {
317 log.Warn().Str("type", msg.Type).Msg("rejecting allowlisted bridge message with invalid token")
318 return true
319 }
320 senderWV.dispatchEditableFocusChanged(payload.Editable)
321 return true
322}
323
324func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {
325 ptr := senderUCM.GoPointer()
326 if ptr == 0 {
327 return nil
328 }
329 return LookupWebViewByUCMPointer(ptr)
330}
331
332func isTrustedBridgeWebView(wv *WebView) bool {
333 if wv == nil {
334 return false
335 }
336 return isTrustedBridgeURI(wv.URI())
337}
338
339func isTrustedBridgeURI(raw string) bool {
340 return isTrustedSystemviewURL(raw)
341}
342
343func (r *MessageRouter) syncWebViewID(wv *WebView) {
344 if wv == nil {
345 return
346 }
347 if !r.markWebViewIDSynced(wv.ID()) {
348 return
349 }
350 script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID()))
351 wv.RunJavaScriptInWorld(r.baseContext(), script, "")
352}
353
354func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool {
355 r.idMu.Lock()
356 defer r.idMu.Unlock()
357 if r.syncedIDs[id] {
358 return false
359 }
360 r.syncedIDs[id] = true
361 return true
362}
363
364// getHandler retrieves a handler by message type.
365func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
366 r.mu.RLock()
367 defer r.mu.RUnlock()
368 entry, ok := r.handlers[msgType]
369 return entry, ok
370}
371
372func (r *MessageRouter) registeredHandlerTypes() []string {
373 r.mu.RLock()
374 defer r.mu.RUnlock()
375 types := make([]string, 0, len(r.handlers))
376 for handlerType := range r.handlers {
377 types = append(types, handlerType)
378 }
379 return types
380}
381
382// dispatchResponse serializes the payload and invokes a window callback in JS.
383func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewID, callback, world string, payload any) error {
384 if ctx == nil {
385 ctx = r.baseContext()
386 }
387
388 if callback == "" {
389 return errors.New("callback name is empty")
390 }
391
392 if webviewID == 0 {
393 return errors.New("webview id is required for JS callback dispatch")
394 }
395
396 wv := LookupWebView(webviewID)
397 if wv == nil {
398 return fmt.Errorf("webview %d not found", webviewID)
399 }
400
401 data, err := json.Marshal(payload)
402 if err != nil {
403 return fmt.Errorf("marshal callback payload: %w", err)
404 }
405
406 script := fmt.Sprintf(
407 `(function(){try{if(window.%[1]s){window.%[1]s(%[2]s);}`+
408 `else{console.warn("dumber callback missing: %[1]s");}}`+
409 `catch(e){console.error("dumber callback %[1]s failed", e);}})();`,
410 callback,
411 string(data),
412 )
413
414 wv.RunJavaScriptInWorld(ctx, script, world)
415 return nil
416}