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 []uint32
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) (uint32, 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().Uint32("signal_id", 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 Uint32("signal_id", 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 if valuePtr == 0 {
178 log.Warn().Msg("received script message with nil value pointer")
179 return
180 }
181
182 jscValue := javascriptcore.ValueNewFromInternalPtr(valuePtr)
183 if jscValue == nil {
184 log.Warn().Msg("failed to wrap script message JSC value")
185 return
186 }
187
188 rawJSON := jscValue.ToJson(0)
189 if rawJSON == "" {
190 log.Warn().Msg("script message JSON is empty")
191 return
192 }
193
194 var msg Message
195 if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil {
196 log.Warn().Err(err).Str("json", rawJSON).Msg("failed to unmarshal script message")
197 return
198 }
199 if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
200 msg.WebViewID = msg.WebViewIDAlt
201 }
202 if msg.WebViewID == 0 {
203 if senderWV := lookupSenderWebView(senderUCM); senderWV != nil {
204 msg.WebViewID = uint64(senderWV.ID())
205 r.syncWebViewID(senderWV)
206 }
207 }
208
209 if msg.Type == "" {
210 log.Warn().Msg("script message missing type")
211 return
212 }
213
214 entry, ok := r.getHandler(msg.Type)
215 if !ok || entry.handler == nil {
216 log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")
217 return
218 }
219
220 log.Info().
221 Str("type", msg.Type).
222 Uint64("webview_id", msg.WebViewID).
223 Int("payload_len", len(msg.Payload)).
224 Msg("received script message")
225
226 resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload)
227 if err != nil {
228 log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
229 if entry.errorCallback != "" {
230 if respErr := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.errorCallback, entry.world, err.Error()); respErr != nil {
231 log.Warn().Err(respErr).Msg("failed to dispatch error callback")
232 }
233 }
234 return
235 }
236
237 if entry.callback != "" {
238 if err := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.callback, entry.world, resp); err != nil {
239 log.Warn().Err(err).
240 Str("callback", entry.callback).
241 Uint64("webview_id", msg.WebViewID).
242 Msg("failed to dispatch callback response")
243 }
244 }
245}
246
247func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {
248 ptr := senderUCM.GoPointer()
249 if ptr == 0 {
250 return nil
251 }
252 return LookupWebViewByUCMPointer(ptr)
253}
254
255func (r *MessageRouter) syncWebViewID(wv *WebView) {
256 if wv == nil {
257 return
258 }
259 if !r.markWebViewIDSynced(wv.ID()) {
260 return
261 }
262 script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID()))
263 wv.RunJavaScript(r.baseCtx, script, "")
264}
265
266func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool {
267 r.idMu.Lock()
268 defer r.idMu.Unlock()
269 if r.syncedIDs[id] {
270 return false
271 }
272 r.syncedIDs[id] = true
273 return true
274}
275
276// getHandler retrieves a handler by message type.
277func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
278 r.mu.RLock()
279 defer r.mu.RUnlock()
280 entry, ok := r.handlers[msgType]
281 return entry, ok
282}
283
284// dispatchResponse serializes the payload and invokes a window callback in JS.
285func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewID, callback, world string, payload any) error {
286 if ctx == nil {
287 ctx = r.baseCtx
288 }
289
290 if callback == "" {
291 return errors.New("callback name is empty")
292 }
293
294 if webviewID == 0 {
295 return errors.New("webview id is required for JS callback dispatch")
296 }
297
298 wv := LookupWebView(webviewID)
299 if wv == nil {
300 return fmt.Errorf("webview %d not found", webviewID)
301 }
302
303 data, err := json.Marshal(payload)
304 if err != nil {
305 return fmt.Errorf("marshal callback payload: %w", err)
306 }
307
308 script := fmt.Sprintf(
309 `(function(){try{if(window.%[1]s){window.%[1]s(%[2]s);}`+
310 `else{console.warn("dumber callback missing: %[1]s");}}`+
311 `catch(e){console.error("dumber callback %[1]s failed", e);}})();`,
312 callback,
313 string(data),
314 )
315
316 wv.RunJavaScript(ctx, script, world)
317 return nil
318}