v0.32.0 0617278

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
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}

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