v0.33.1 9577fee

messaging.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"fmt"
8	"sync"
9
10	"github.com/bnema/dumber/internal/application/port"
11	"github.com/bnema/dumber/internal/logging"
12	"github.com/bnema/puregotk/v4/javascriptcore"
13	"github.com/bnema/puregotk/v4/webkit"
14	"github.com/rs/zerolog"
15)
16
17// Message is the JS -> Go message envelope sent via postMessage.
18type Message = port.WebUIMessage
19
20// MessageHandler is an alias for port.WebUIMessageHandler.
21// Kept for backward compatibility within the webkit package.
22type MessageHandler = port.WebUIMessageHandler
23
24// MessageHandlerFunc is an alias for port.WebUIMessageHandlerFunc.
25// Kept for backward compatibility within the webkit package.
26type MessageHandlerFunc = port.WebUIMessageHandlerFunc
27
28type handlerEntry struct {
29	handler       MessageHandler
30	callback      string
31	errorCallback string
32	world         string
33}
34
35// Compile-time check that MessageRouter implements port.WebUIHandlerRouter.
36var _ port.WebUIHandlerRouter = (*MessageRouter)(nil)
37
38// MessageRouter dispatches script-message events to registered handlers.
39type MessageRouter struct {
40	handlers map[string]handlerEntry
41	baseCtx  context.Context
42
43	mu        sync.RWMutex
44	callbacks []any
45	signals   []uint
46
47	idMu      sync.Mutex
48	syncedIDs map[WebViewID]bool
49}
50
51// NewMessageRouter creates a new message router.
52func NewMessageRouter(ctx context.Context) *MessageRouter {
53	if ctx == nil {
54		ctx = context.Background()
55	}
56
57	return &MessageRouter{
58		handlers:  make(map[string]handlerEntry),
59		baseCtx:   ctx,
60		syncedIDs: make(map[WebViewID]bool),
61	}
62}
63
64// SetBaseContext updates the base context used for logging and handler execution.
65func (r *MessageRouter) SetBaseContext(ctx context.Context) {
66	if r == nil {
67		return
68	}
69	if ctx == nil {
70		ctx = context.Background()
71	}
72	r.mu.Lock()
73	r.baseCtx = ctx
74	r.mu.Unlock()
75}
76
77func (r *MessageRouter) baseContext() context.Context {
78	if r == nil {
79		return context.Background()
80	}
81	r.mu.RLock()
82	ctx := r.baseCtx
83	r.mu.RUnlock()
84	if ctx == nil {
85		return context.Background()
86	}
87	return ctx
88}
89
90// RegisterHandler registers a handler for a message type.
91func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {
92	if msgType == "" {
93		return errors.New("message type cannot be empty")
94	}
95	if handler == nil {
96		return errors.New("message handler cannot be nil")
97	}
98
99	r.mu.Lock()
100	defer r.mu.Unlock()
101	r.handlers[msgType] = handlerEntry{handler: handler}
102	return nil
103}
104
105// RegisterHandlerWithCallbacks registers a handler and response callbacks.
106// callback is invoked on success, errorCallback (optional) on failure.
107// worldName allows targeting a specific script world (empty for main world).
108func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCallback, worldName string, handler MessageHandler) error {
109	if msgType == "" {
110		return errors.New("message type cannot be empty")
111	}
112	if handler == nil {
113		return errors.New("message handler cannot be nil")
114	}
115	if callback == "" {
116		return errors.New("callback cannot be empty when registering with callbacks")
117	}
118
119	r.mu.Lock()
120	defer r.mu.Unlock()
121	r.handlers[msgType] = handlerEntry{
122		handler:       handler,
123		callback:      callback,
124		errorCallback: errorCallback,
125		world:         worldName,
126	}
127	return nil
128}
129
130// SetupMessageHandler wires the router into the given UserContentManager.
131// It registers the script message handler in the MAIN world (not isolated).
132// WebKit's messageHandlers is only available in main world.
133// The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler.
134func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint, error) {
135	baseCtx := r.baseContext()
136	log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger()
137
138	log.Debug().Msg("SetupMessageHandler called")
139
140	if ucm == nil {
141		log.Warn().Msg("SetupMessageHandler: ucm is nil")
142		return 0, errors.New("user content manager is nil")
143	}
144
145	log.Debug().Msg("SetupMessageHandler: connecting signal with detail")
146
147	// Connect to signal BEFORE registering handler to avoid race conditions
148	// (as recommended by WebKit documentation)
149	cb := func(ucm webkit.UserContentManager, valuePtr uintptr) {
150		log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked")
151		r.handleScriptMessage(ucm, valuePtr)
152	}
153
154	r.mu.Lock()
155	r.callbacks = append(r.callbacks, cb) // keep callback alive
156	r.mu.Unlock()
157
158	// Connect to "script-message-received::dumber" with handler name as signal detail
159	signalID := ucm.ConnectScriptMessageReceivedWithDetail(MessageHandlerName, &cb)
160	log.Debug().Uint64("signal_id", uint64(signalID)).Str("handler", MessageHandlerName).Msg("signal connected with detail")
161
162	r.mu.Lock()
163	r.signals = append(r.signals, signalID)
164	r.mu.Unlock()
165
166	log.Debug().Msg("SetupMessageHandler: registering handler in main world")
167
168	// Register handler in main world - webkit.messageHandlers is only available there
169	// nil = main/default world (NULL in C), empty string "" would be a world named ""
170	if ok := ucm.RegisterScriptMessageHandler(MessageHandlerName, nil); !ok {
171		log.Warn().Str("handler", MessageHandlerName).Msg("RegisterScriptMessageHandler returned false")
172		return 0, fmt.Errorf("failed to register script message handler %q in main world", MessageHandlerName)
173	}
174
175	log.Info().
176		Str("handler", MessageHandlerName).
177		Str("world", "main").
178		Uint64("signal_id", uint64(signalID)).
179		Msg("script message handler connected")
180
181	return signalID, nil
182}
183
184// handleScriptMessage decodes the JSC value and routes it to the correct handler.
185func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) {
186	baseCtx := r.baseContext()
187	log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger()
188
189	log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry")
190
191	msg, ok := decodeScriptMessage(valuePtr, log)
192	if !ok {
193		return
194	}
195
196	senderWV := lookupSenderWebView(senderUCM)
197	if senderWV == nil {
198		log.Warn().Msg("rejecting script message from unknown sender")
199		return
200	}
201	if r.handleAllowlistedBridgeMessage(senderWV, msg) {
202		return
203	}
204	if !isTrustedBridgeWebView(senderWV) {
205		log.Warn().
206			Str("type", msg.Type).
207			Uint64("sender_webview_id", uint64(senderWV.ID())).
208			Str("uri", senderWV.URI()).
209			Msg("rejecting script message from untrusted page")
210		return
211	}
212
213	// Trust the sender WebView resolved from WebKit's UserContentManager, not a
214	// caller-provided webViewId field in page-controlled JSON.
215	msg.WebViewID = uint64(senderWV.ID())
216	r.syncWebViewID(senderWV)
217
218	if msg.Type == "" {
219		log.Warn().Msg("script message missing type")
220		return
221	}
222
223	entry, ok := r.getHandler(msg.Type)
224	if !ok || entry.handler == nil {
225		handlerTypes := r.registeredHandlerTypes()
226		log.Warn().Str("type", msg.Type).Int("registered_handlers", len(handlerTypes)).Msg("no handler registered for message type")
227		for _, handlerType := range handlerTypes {
228			log.Debug().Str("registered_type", handlerType).Msg("available handler")
229		}
230		return
231	}
232
233	log.Info().
234		Str("type", msg.Type).
235		Uint64("webview_id", msg.WebViewID).
236		Int("payload_len", len(msg.Payload)).
237		Str("callback", entry.callback).
238		Msg("received script message, dispatching to handler")
239
240	resp, err := entry.handler.Handle(baseCtx, WebViewID(msg.WebViewID), msg.Payload)
241	log.Debug().Str("type", msg.Type).Err(err).Msg("handler execution completed")
242	if err != nil {
243		log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
244		if entry.errorCallback != "" {
245			if respErr := r.dispatchResponse(baseCtx, WebViewID(msg.WebViewID), entry.errorCallback, entry.world, err.Error()); respErr != nil {
246				log.Warn().Err(respErr).Msg("failed to dispatch error callback")
247			}
248		}
249		return
250	}
251
252	if entry.callback != "" {
253		if err := r.dispatchResponse(baseCtx, WebViewID(msg.WebViewID), entry.callback, entry.world, resp); err != nil {
254			log.Warn().Err(err).
255				Str("callback", entry.callback).
256				Uint64("webview_id", msg.WebViewID).
257				Msg("failed to dispatch callback response")
258		}
259	}
260}
261
262func decodeScriptMessage(valuePtr uintptr, log zerolog.Logger) (Message, bool) {
263	if valuePtr == 0 {
264		log.Warn().Msg("received script message with nil value pointer")
265		return Message{}, false
266	}
267
268	jscValue := javascriptcore.ValueNewFromInternalPtr(valuePtr)
269	if jscValue == nil {
270		log.Warn().Msg("failed to wrap script message JSC value")
271		return Message{}, false
272	}
273
274	rawJSON := jscValue.ToJson(0)
275	log.Debug().Int("json_len", len(rawJSON)).Msg("ToJson result")
276	if rawJSON == "" {
277		log.Error().
278			Msg("script message JSON serialization failed (ToJson returned empty) - " +
279				"this usually means JS sent non-serializable values like Proxy objects")
280		return Message{}, false
281	}
282
283	var msg Message
284	if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil {
285		log.Warn().Err(err).Int("json_len", len(rawJSON)).Msg("failed to unmarshal script message")
286		return Message{}, false
287	}
288	return msg, true
289}
290
291// handleAllowlistedBridgeMessage handles the one narrow page-origin hint that
292// is allowed before trusted-page routing: editable focus state for Vim mode.
293// This is intentionally not a privileged bridge command; the only effect is
294// updating editable-focus state on the sender WebView so Vim mode can block or
295// exit locally. The per-WebView token is a reuse/staleness guard, not a broad
296// authorization boundary for page privileges.
297func (r *MessageRouter) handleAllowlistedBridgeMessage(senderWV *WebView, msg Message) bool {
298	if msg.Type != "editable_focus_changed" {
299		return false
300	}
301	log := logging.FromContext(r.baseContext())
302	if senderWV == nil {
303		log.Warn().Str("type", msg.Type).Msg("dropping allowlisted bridge message without sender WebView")
304		return true
305	}
306	var payload struct {
307		Editable bool   `json:"editable"`
308		Token    string `json:"token"`
309	}
310	if len(msg.Payload) != 0 {
311		if err := json.Unmarshal(msg.Payload, &payload); err != nil {
312			log.Warn().Err(err).Str("type", msg.Type).Msg("failed to decode allowlisted bridge payload")
313			return true
314		}
315	}
316	if payload.Token == "" || !senderWV.matchesEditableFocusBridgeToken(payload.Token) {
317		log.Warn().Str("type", msg.Type).Msg("rejecting allowlisted bridge message with invalid token")
318		return true
319	}
320	senderWV.dispatchEditableFocusChanged(payload.Editable)
321	return true
322}
323
324func lookupSenderWebView(senderUCM webkit.UserContentManager) *WebView {
325	ptr := senderUCM.GoPointer()
326	if ptr == 0 {
327		return nil
328	}
329	return LookupWebViewByUCMPointer(ptr)
330}
331
332func isTrustedBridgeWebView(wv *WebView) bool {
333	if wv == nil {
334		return false
335	}
336	return isTrustedBridgeURI(wv.URI())
337}
338
339func isTrustedBridgeURI(raw string) bool {
340	return isTrustedSystemviewURL(raw)
341}
342
343func (r *MessageRouter) syncWebViewID(wv *WebView) {
344	if wv == nil {
345		return
346	}
347	if !r.markWebViewIDSynced(wv.ID()) {
348		return
349	}
350	script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID()))
351	wv.RunJavaScriptInWorld(r.baseContext(), script, "")
352}
353
354func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool {
355	r.idMu.Lock()
356	defer r.idMu.Unlock()
357	if r.syncedIDs[id] {
358		return false
359	}
360	r.syncedIDs[id] = true
361	return true
362}
363
364// getHandler retrieves a handler by message type.
365func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
366	r.mu.RLock()
367	defer r.mu.RUnlock()
368	entry, ok := r.handlers[msgType]
369	return entry, ok
370}
371
372func (r *MessageRouter) registeredHandlerTypes() []string {
373	r.mu.RLock()
374	defer r.mu.RUnlock()
375	types := make([]string, 0, len(r.handlers))
376	for handlerType := range r.handlers {
377		types = append(types, handlerType)
378	}
379	return types
380}
381
382// dispatchResponse serializes the payload and invokes a window callback in JS.
383func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewID, callback, world string, payload any) error {
384	if ctx == nil {
385		ctx = r.baseContext()
386	}
387
388	if callback == "" {
389		return errors.New("callback name is empty")
390	}
391
392	if webviewID == 0 {
393		return errors.New("webview id is required for JS callback dispatch")
394	}
395
396	wv := LookupWebView(webviewID)
397	if wv == nil {
398		return fmt.Errorf("webview %d not found", webviewID)
399	}
400
401	data, err := json.Marshal(payload)
402	if err != nil {
403		return fmt.Errorf("marshal callback payload: %w", err)
404	}
405
406	script := fmt.Sprintf(
407		`(function(){try{if(window.%[1]s){window.%[1]s(%[2]s);}`+
408			`else{console.warn("dumber callback missing: %[1]s");}}`+
409			`catch(e){console.error("dumber callback %[1]s failed", e);}})();`,
410		callback,
411		string(data),
412	)
413
414	wv.RunJavaScriptInWorld(ctx, script, world)
415	return nil
416}

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