v0.30.0 eb3068f

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

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