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