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
53// NewMessageRouter creates a new message router.
54func NewMessageRouter(ctx context.Context) *MessageRouter {
55 if ctx == nil {
56 ctx = context.Background()
57 }
58
59 return &MessageRouter{
60 handlers: make(map[string]handlerEntry),
61 baseCtx: ctx,
62 }
63}
64
65// SetBaseContext updates the base context used for logging and handler execution.
66func (r *MessageRouter) SetBaseContext(ctx context.Context) {
67 if r == nil {
68 return
69 }
70 if ctx == nil {
71 ctx = context.Background()
72 }
73 r.baseCtx = ctx
74}
75
76// RegisterHandler registers a handler for a message type.
77func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {
78 if msgType == "" {
79 return errors.New("message type cannot be empty")
80 }
81 if handler == nil {
82 return errors.New("message handler cannot be nil")
83 }
84
85 r.mu.Lock()
86 defer r.mu.Unlock()
87 r.handlers[msgType] = handlerEntry{handler: handler}
88 return nil
89}
90
91// RegisterHandlerWithCallbacks registers a handler and response callbacks.
92// callback is invoked on success, errorCallback (optional) on failure.
93// worldName allows targeting a specific script world (empty for main world).
94func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCallback, worldName string, handler MessageHandler) error {
95 if msgType == "" {
96 return errors.New("message type cannot be empty")
97 }
98 if handler == nil {
99 return errors.New("message handler cannot be nil")
100 }
101 if callback == "" {
102 return errors.New("callback cannot be empty when registering with callbacks")
103 }
104
105 r.mu.Lock()
106 defer r.mu.Unlock()
107 r.handlers[msgType] = handlerEntry{
108 handler: handler,
109 callback: callback,
110 errorCallback: errorCallback,
111 world: worldName,
112 }
113 return nil
114}
115
116// SetupMessageHandler wires the router into the given UserContentManager.
117// It registers the script message handler in the MAIN world (not isolated).
118// WebKit's messageHandlers is only available in main world.
119// The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler.
120func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint32, error) {
121 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
122
123 log.Debug().Msg("SetupMessageHandler called")
124
125 if ucm == nil {
126 log.Warn().Msg("SetupMessageHandler: ucm is nil")
127 return 0, errors.New("user content manager is nil")
128 }
129
130 log.Debug().Msg("SetupMessageHandler: connecting signal with detail")
131
132 // Connect to signal BEFORE registering handler to avoid race conditions
133 // (as recommended by WebKit documentation)
134 cb := func(_ webkit.UserContentManager, valuePtr uintptr) {
135 log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked")
136 r.handleScriptMessage(valuePtr)
137 }
138
139 r.mu.Lock()
140 r.callbacks = append(r.callbacks, cb) // keep callback alive
141 r.mu.Unlock()
142
143 // Connect to "script-message-received::dumber" with handler name as signal detail
144 signalID := ucm.ConnectScriptMessageReceivedWithDetail(MessageHandlerName, &cb)
145 log.Debug().Uint32("signal_id", signalID).Str("handler", MessageHandlerName).Msg("signal connected with detail")
146
147 r.mu.Lock()
148 r.signals = append(r.signals, signalID)
149 r.mu.Unlock()
150
151 log.Debug().Msg("SetupMessageHandler: registering handler in main world")
152
153 // Register handler in main world - webkit.messageHandlers is only available there
154 // nil = main/default world (NULL in C), empty string "" would be a world named ""
155 if ok := ucm.RegisterScriptMessageHandler(MessageHandlerName, nil); !ok {
156 log.Warn().Str("handler", MessageHandlerName).Msg("RegisterScriptMessageHandler returned false")
157 return 0, fmt.Errorf("failed to register script message handler %q in main world", MessageHandlerName)
158 }
159
160 log.Info().
161 Str("handler", MessageHandlerName).
162 Str("world", "main").
163 Uint32("signal_id", signalID).
164 Msg("script message handler connected")
165
166 return signalID, nil
167}
168
169// handleScriptMessage decodes the JSC value and routes it to the correct handler.
170func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
171 log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
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 if rawJSON == "" {
186 log.Warn().Msg("script message JSON is empty")
187 return
188 }
189
190 var msg Message
191 if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil {
192 log.Warn().Err(err).Str("json", rawJSON).Msg("failed to unmarshal script message")
193 return
194 }
195 if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
196 msg.WebViewID = msg.WebViewIDAlt
197 }
198
199 if msg.Type == "" {
200 log.Warn().Msg("script message missing type")
201 return
202 }
203
204 entry, ok := r.getHandler(msg.Type)
205 if !ok || entry.handler == nil {
206 log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")
207 return
208 }
209
210 log.Info().
211 Str("type", msg.Type).
212 Uint64("webview_id", msg.WebViewID).
213 Int("payload_len", len(msg.Payload)).
214 Msg("received script message")
215
216 resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload)
217 if err != nil {
218 log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
219 if entry.errorCallback != "" {
220 if respErr := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.errorCallback, entry.world, err.Error()); respErr != nil {
221 log.Warn().Err(respErr).Msg("failed to dispatch error callback")
222 }
223 }
224 return
225 }
226
227 if entry.callback != "" {
228 if err := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.callback, entry.world, resp); err != nil {
229 log.Warn().Err(err).
230 Str("callback", entry.callback).
231 Uint64("webview_id", msg.WebViewID).
232 Msg("failed to dispatch callback response")
233 }
234 }
235}
236
237// getHandler retrieves a handler by message type.
238func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
239 r.mu.RLock()
240 defer r.mu.RUnlock()
241 entry, ok := r.handlers[msgType]
242 return entry, ok
243}
244
245// dispatchResponse serializes the payload and invokes a window callback in JS.
246func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewID, callback, world string, payload any) error {
247 if ctx == nil {
248 ctx = r.baseCtx
249 }
250
251 if callback == "" {
252 return errors.New("callback name is empty")
253 }
254
255 if webviewID == 0 {
256 return errors.New("webview id is required for JS callback dispatch")
257 }
258
259 wv := LookupWebView(webviewID)
260 if wv == nil {
261 return fmt.Errorf("webview %d not found", webviewID)
262 }
263
264 data, err := json.Marshal(payload)
265 if err != nil {
266 return fmt.Errorf("marshal callback payload: %w", err)
267 }
268
269 script := fmt.Sprintf(
270 `(function(){try{if(window.%[1]s){window.%[1]s(%[2]s);}`+
271 `else{console.warn("dumber callback missing: %[1]s");}}`+
272 `catch(e){console.error("dumber callback %[1]s failed", e);}})();`,
273 callback,
274 string(data),
275 )
276
277 wv.RunJavaScript(ctx, script, world)
278 return nil
279}