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