1//go:build js && wasm
2
3package systemviewsbridge
4
5import (
6 "bytes"
7 "context"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "strings"
12 "sync"
13 "syscall/js"
14
15 "github.com/bnema/dumber/internal/application/port"
16)
17
18type callbackPlan struct {
19 success string
20 failure string
21}
22
23func NewBrowserClient() *Client {
24 return NewClient(&webkitTransport{}, &browserTransport{})
25}
26
27func callbackPlanForMessage(msgType string) (callbackPlan, bool) {
28 cb, ok := port.WebUIMessageCallbacks[msgType]
29 if !ok {
30 return callbackPlan{}, false
31 }
32 return callbackPlan{success: cb.Success, failure: cb.Failure}, true
33}
34
35type browserTransport struct{}
36
37func (*browserTransport) Available() bool {
38 window := js.Global().Get("window")
39 if !window.Truthy() {
40 return false
41 }
42 bridge := window.Get("dumber")
43 return bridge.Truthy() && bridge.Get("postMessage").Truthy()
44}
45
46func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
47 req, err := decodeBrowserRequest(body)
48 if err != nil {
49 return nil, err
50 }
51 if req.directAPI {
52 return fetchDirectAPI(ctx, req.requestID, req.messageType)
53 }
54
55 window := js.Global().Get("window")
56 bridge := window.Get("dumber")
57 if !bridge.Truthy() || !bridge.Get("postMessage").Truthy() {
58 return nil, errors.New("browser bridge shim not available")
59 }
60
61 promise := bridge.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))
62 value, err := awaitPromise(ctx, promise)
63 if err != nil {
64 return nil, err
65 }
66 raw, err := stringifyValue(value)
67 if err != nil {
68 return nil, err
69 }
70 return normalizeBridgeShimResponse(req.requestID, raw)
71}
72
73type webkitTransport struct{}
74
75type webkitCallbackResult struct {
76 body []byte
77 err error
78}
79
80type webkitCallbackWaiter struct {
81 requestID string
82 successName string
83 failureName string
84 resultCh chan webkitCallbackResult
85}
86
87// webkitCallbacks uses one page-lifetime dispatcher per native callback name.
88// Multiple requests may share callback names concurrently. When native payloads
89// include requestId, dispatch to that waiter; older callbacks without requestId
90// are resolved FIFO so they cannot target released js.Func handles.
91var webkitCallbacks = struct {
92 sync.Mutex
93 installed map[string]js.Func
94 waiters map[string][]*webkitCallbackWaiter
95}{
96 installed: make(map[string]js.Func),
97 waiters: make(map[string][]*webkitCallbackWaiter),
98}
99
100func (*webkitTransport) Available() bool {
101 window := js.Global().Get("window")
102 if !window.Truthy() {
103 return false
104 }
105 webkit := window.Get("webkit")
106 if !webkit.Truthy() {
107 return false
108 }
109 handlers := webkit.Get("messageHandlers")
110 if !handlers.Truthy() {
111 return false
112 }
113 return handlers.Get("dumber").Truthy()
114}
115
116func (*webkitTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
117 req, err := decodeBrowserRequest(body)
118 if err != nil {
119 return nil, err
120 }
121 if req.directAPI {
122 return fetchDirectAPI(ctx, req.requestID, req.messageType)
123 }
124
125 plan, ok := callbackPlanForMessage(req.messageType)
126 if !ok {
127 return nil, fmt.Errorf("no WebKit callback plan for message type %q", req.messageType)
128 }
129
130 window := js.Global().Get("window")
131 handler := window.Get("webkit").Get("messageHandlers").Get("dumber")
132 if !handler.Truthy() {
133 return nil, errors.New("webkit message handler not available")
134 }
135
136 ensureWebKitCallback(window, plan.success, false)
137 ensureWebKitCallback(window, plan.failure, true)
138
139 resultCh := make(chan webkitCallbackResult, 1)
140 unregister, err := registerWebKitWaiter(req.requestID, plan, resultCh)
141 if err != nil {
142 return nil, err
143 }
144
145 handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))
146
147 select {
148 case res := <-resultCh:
149 return res.body, res.err
150 case <-ctx.Done():
151 unregister()
152 return nil, ctx.Err()
153 }
154}
155
156func ensureWebKitCallback(window js.Value, name string, failure bool) {
157 webkitCallbacks.Lock()
158 if _, ok := webkitCallbacks.installed[name]; ok {
159 webkitCallbacks.Unlock()
160 return
161 }
162 callback := js.FuncOf(func(_ js.Value, args []js.Value) any {
163 handleWebKitCallback(name, failure, args)
164 return nil
165 })
166 webkitCallbacks.installed[name] = callback
167 // Installed callbacks are page-lifetime dispatchers. Per-request waiters are
168 // removed on completion/cancellation, so late native responses are dropped
169 // without invoking released js.Func handles.
170 window.Set(name, callback)
171 webkitCallbacks.Unlock()
172}
173
174func registerWebKitWaiter(requestID string, plan callbackPlan, resultCh chan webkitCallbackResult) (func(), error) {
175 waiter := &webkitCallbackWaiter{
176 requestID: requestID,
177 successName: plan.success,
178 failureName: plan.failure,
179 resultCh: resultCh,
180 }
181 webkitCallbacks.Lock()
182 defer webkitCallbacks.Unlock()
183 webkitCallbacks.waiters[plan.success] = append(webkitCallbacks.waiters[plan.success], waiter)
184 webkitCallbacks.waiters[plan.failure] = append(webkitCallbacks.waiters[plan.failure], waiter)
185 return func() {
186 webkitCallbacks.Lock()
187 defer webkitCallbacks.Unlock()
188 removeWebKitWaiterLocked(waiter)
189 }, nil
190}
191
192func findWebKitWaiterLocked(name, requestID string) *webkitCallbackWaiter {
193 waiters := webkitCallbacks.waiters[name]
194 if len(waiters) == 0 {
195 return nil
196 }
197 if requestID == "" {
198 return waiters[0]
199 }
200 for _, waiter := range waiters {
201 if waiter != nil && waiter.requestID == requestID {
202 return waiter
203 }
204 }
205 return nil
206}
207
208func removeWebKitWaiterLocked(waiter *webkitCallbackWaiter) {
209 if waiter == nil {
210 return
211 }
212 remove := func(name string) {
213 waiters := webkitCallbacks.waiters[name]
214 for i, candidate := range waiters {
215 if candidate != waiter {
216 continue
217 }
218 waiters = append(waiters[:i], waiters[i+1:]...)
219 if len(waiters) == 0 {
220 delete(webkitCallbacks.waiters, name)
221 } else {
222 webkitCallbacks.waiters[name] = waiters
223 }
224 return
225 }
226 }
227 remove(waiter.successName)
228 remove(waiter.failureName)
229}
230
231func handleWebKitCallback(name string, failure bool, args []js.Value) {
232 payload, payloadErr := stringifyArgs(args)
233 payloadRequestID := requestIDFromBridgePayload(payload)
234
235 webkitCallbacks.Lock()
236 waiter := findWebKitWaiterLocked(name, payloadRequestID)
237 if waiter != nil {
238 removeWebKitWaiterLocked(waiter)
239 }
240 webkitCallbacks.Unlock()
241
242 if waiter == nil {
243 warnDroppedBridgeResult("webkit callback without waiter", nil)
244 return
245 }
246
247 res := webkitCallbackResult{err: payloadErr}
248 if payloadErr == nil {
249 if failure {
250 res.body, res.err = normalizeNativeErrorResponse(waiter.requestID, payload)
251 } else {
252 res.body, res.err = normalizeNativeSuccessResponse(waiter.requestID, payload)
253 }
254 }
255 select {
256 case waiter.resultCh <- res:
257 default:
258 warnDroppedBridgeResult("webkit callback", res.err)
259 }
260}
261
262func requestIDFromBridgePayload(payload []byte) string {
263 if len(bytes.TrimSpace(payload)) == 0 {
264 return ""
265 }
266 var envelope struct {
267 RequestID string `json:"requestId"`
268 }
269 if err := json.Unmarshal(payload, &envelope); err != nil {
270 return ""
271 }
272 return envelope.RequestID
273}
274
275func fetchDirectAPI(ctx context.Context, requestID, endpoint string) ([]byte, error) {
276 fetch := js.Global().Get("fetch")
277 if !fetch.Truthy() {
278 return nil, errors.New("fetch API not available")
279 }
280 response, err := awaitPromise(ctx, fetch.Invoke(endpoint))
281 if err != nil {
282 return nil, err
283 }
284 if !response.Get("ok").Bool() {
285 status := response.Get("status").Int()
286 statusText := response.Get("statusText").String()
287 if statusText == "" {
288 statusText = "request failed"
289 }
290 return nil, fmt.Errorf("direct API %s failed: %d %s", endpoint, status, statusText)
291 }
292 data, err := awaitPromise(ctx, response.Call("json"))
293 if err != nil {
294 return nil, err
295 }
296 raw, err := stringifyValue(data)
297 if err != nil {
298 return nil, err
299 }
300 return wrapDirectAPIResponse(requestID, raw)
301}
302
303func normalizeNativeSuccessResponse(requestID string, payload []byte) ([]byte, error) {
304 trimmed := bytes.TrimSpace(payload)
305 if len(trimmed) == 0 {
306 return json.Marshal(bridgeResponse{RequestID: requestID, Success: true})
307 }
308
309 var resp bridgeResponse
310 if err := json.Unmarshal(trimmed, &resp); err == nil {
311 hasEnvelope := resp.Success || resp.RequestID != "" || resp.Error != ""
312 if hasEnvelope {
313 if resp.RequestID == "" {
314 resp.RequestID = requestID
315 }
316 return json.Marshal(resp)
317 }
318 }
319
320 return json.Marshal(bridgeResponse{RequestID: requestID, Success: true, Data: json.RawMessage(trimmed)})
321}
322
323func normalizeNativeErrorResponse(requestID string, payload []byte) ([]byte, error) {
324 trimmed := bytes.TrimSpace(payload)
325 var message string
326 if len(trimmed) == 0 {
327 message = "bridge request failed"
328 } else if err := json.Unmarshal(trimmed, &message); err != nil {
329 message = strings.Trim(string(trimmed), `"`)
330 }
331 if message == "" {
332 message = "bridge request failed"
333 }
334 return json.Marshal(bridgeResponse{RequestID: requestID, Error: message})
335}
336
337func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) {
338 if ctx == nil {
339 ctx = context.Background()
340 }
341 type result struct {
342 value js.Value
343 err error
344 }
345 resultCh := make(chan result, 1)
346
347 promiseCtor := js.Global().Get("Promise")
348 if !promiseCtor.Truthy() {
349 return js.Undefined(), errors.New("Promise global not available")
350 }
351 var (
352 cancelMu sync.Mutex
353 reject = js.Undefined()
354 )
355 executor := js.FuncOf(func(_ js.Value, args []js.Value) any {
356 if len(args) >= 2 {
357 cancelMu.Lock()
358 reject = args[1]
359 cancelMu.Unlock()
360 }
361 return nil
362 })
363 cancelPromise := promiseCtor.New(executor)
364 executor.Release()
365 raceInput := js.Global().Get("Array").New(promise, cancelPromise)
366 racePromise := promiseCtor.Call("race", raceInput)
367
368 done := make(chan struct{})
369 go func() {
370 select {
371 case <-ctx.Done():
372 cancelMu.Lock()
373 rejectFn := reject
374 cancelMu.Unlock()
375 if rejectFn.Truthy() {
376 rejectFn.Invoke(ctx.Err().Error())
377 }
378 case <-done:
379 }
380 }()
381 defer close(done)
382
383 var (
384 callbackMu sync.Mutex
385 released bool
386 thenCb js.Func
387 catchCb js.Func
388 )
389 releaseCallbacks := func() {
390 callbackMu.Lock()
391 defer callbackMu.Unlock()
392 if released {
393 return
394 }
395 released = true
396 thenCb.Release()
397 catchCb.Release()
398 }
399 publish := func(res result) {
400 select {
401 case resultCh <- res:
402 default:
403 warnDroppedBridgeResult("promise", res.err)
404 }
405 }
406
407 thenCb = js.FuncOf(func(_ js.Value, args []js.Value) any {
408 defer releaseCallbacks()
409 if len(args) == 0 {
410 publish(result{value: js.Undefined()})
411 return nil
412 }
413 publish(result{value: args[0]})
414 return nil
415 })
416 catchCb = js.FuncOf(func(_ js.Value, args []js.Value) any {
417 defer releaseCallbacks()
418 if len(args) == 0 {
419 publish(result{err: errors.New("promise rejected")})
420 return nil
421 }
422 message := args[0].String()
423 if ctxErr := ctx.Err(); ctxErr != nil && message == ctxErr.Error() {
424 publish(result{err: ctxErr})
425 return nil
426 }
427 publish(result{err: errors.New(message)})
428 return nil
429 })
430 attachPromiseCallbacks(racePromise, thenCb, catchCb)
431
432 res := <-resultCh
433 return res.value, res.err
434}
435
436func attachPromiseCallbacks(promise js.Value, thenCb, catchCb js.Func) {
437 promise.Call("then", thenCb, catchCb)
438}
439
440func warnDroppedBridgeResult(scope string, err error) {
441 console := js.Global().Get("console")
442 if !console.Truthy() || !console.Get("warn").Truthy() {
443 return
444 }
445 if err != nil {
446 console.Call("warn", "dumber systemviews bridge dropped "+scope+" result", err.Error())
447 return
448 }
449 console.Call("warn", "dumber systemviews bridge dropped "+scope+" result")
450}
451
452func stringifyArgs(args []js.Value) ([]byte, error) {
453 if len(args) == 0 {
454 return nil, nil
455 }
456 return stringifyValue(args[0])
457}
458
459func stringifyValue(value js.Value) ([]byte, error) {
460 jsonValue := js.Global().Get("JSON")
461 if !jsonValue.Truthy() {
462 return nil, errors.New("JSON global not available")
463 }
464 stringified := jsonValue.Call("stringify", value)
465 if stringified.Type() == js.TypeString {
466 return []byte(stringified.String()), nil
467 }
468 if stringified.IsUndefined() && (value.IsUndefined() || value.IsNull()) {
469 return []byte("null"), nil
470 }
471 return nil, errors.New("failed to stringify JS value")
472}