browser_transport_js.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close