v0.27.2 5960dd8

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

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