v0.29.0 21b3150

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

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