v0.22.0 a85da87

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   []uint32
51}
52
53// NewMessageRouter creates a new message router.
54func NewMessageRouter(ctx context.Context) *MessageRouter {
55	if ctx == nil {
56		ctx = context.Background()
57	}
58
59	return &MessageRouter{
60		handlers: make(map[string]handlerEntry),
61		baseCtx:  ctx,
62	}
63}
64
65// SetBaseContext updates the base context used for logging and handler execution.
66func (r *MessageRouter) SetBaseContext(ctx context.Context) {
67	if r == nil {
68		return
69	}
70	if ctx == nil {
71		ctx = context.Background()
72	}
73	r.baseCtx = ctx
74}
75
76// RegisterHandler registers a handler for a message type.
77func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {
78	if msgType == "" {
79		return errors.New("message type cannot be empty")
80	}
81	if handler == nil {
82		return errors.New("message handler cannot be nil")
83	}
84
85	r.mu.Lock()
86	defer r.mu.Unlock()
87	r.handlers[msgType] = handlerEntry{handler: handler}
88	return nil
89}
90
91// RegisterHandlerWithCallbacks registers a handler and response callbacks.
92// callback is invoked on success, errorCallback (optional) on failure.
93// worldName allows targeting a specific script world (empty for main world).
94func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCallback, worldName string, handler MessageHandler) error {
95	if msgType == "" {
96		return errors.New("message type cannot be empty")
97	}
98	if handler == nil {
99		return errors.New("message handler cannot be nil")
100	}
101	if callback == "" {
102		return errors.New("callback cannot be empty when registering with callbacks")
103	}
104
105	r.mu.Lock()
106	defer r.mu.Unlock()
107	r.handlers[msgType] = handlerEntry{
108		handler:       handler,
109		callback:      callback,
110		errorCallback: errorCallback,
111		world:         worldName,
112	}
113	return nil
114}
115
116// SetupMessageHandler wires the router into the given UserContentManager.
117// It registers the script message handler in the MAIN world (not isolated).
118// WebKit's messageHandlers is only available in main world.
119// The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler.
120func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint32, error) {
121	log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
122
123	log.Debug().Msg("SetupMessageHandler called")
124
125	if ucm == nil {
126		log.Warn().Msg("SetupMessageHandler: ucm is nil")
127		return 0, errors.New("user content manager is nil")
128	}
129
130	log.Debug().Msg("SetupMessageHandler: connecting signal with detail")
131
132	// Connect to signal BEFORE registering handler to avoid race conditions
133	// (as recommended by WebKit documentation)
134	cb := func(_ webkit.UserContentManager, valuePtr uintptr) {
135		log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("signal callback invoked")
136		r.handleScriptMessage(valuePtr)
137	}
138
139	r.mu.Lock()
140	r.callbacks = append(r.callbacks, cb) // keep callback alive
141	r.mu.Unlock()
142
143	// Connect to "script-message-received::dumber" with handler name as signal detail
144	signalID := ucm.ConnectScriptMessageReceivedWithDetail(MessageHandlerName, &cb)
145	log.Debug().Uint32("signal_id", signalID).Str("handler", MessageHandlerName).Msg("signal connected with detail")
146
147	r.mu.Lock()
148	r.signals = append(r.signals, signalID)
149	r.mu.Unlock()
150
151	log.Debug().Msg("SetupMessageHandler: registering handler in main world")
152
153	// Register handler in main world - webkit.messageHandlers is only available there
154	// nil = main/default world (NULL in C), empty string "" would be a world named ""
155	if ok := ucm.RegisterScriptMessageHandler(MessageHandlerName, nil); !ok {
156		log.Warn().Str("handler", MessageHandlerName).Msg("RegisterScriptMessageHandler returned false")
157		return 0, fmt.Errorf("failed to register script message handler %q in main world", MessageHandlerName)
158	}
159
160	log.Info().
161		Str("handler", MessageHandlerName).
162		Str("world", "main").
163		Uint32("signal_id", signalID).
164		Msg("script message handler connected")
165
166	return signalID, nil
167}
168
169// handleScriptMessage decodes the JSC value and routes it to the correct handler.
170func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
171	log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
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	if rawJSON == "" {
186		log.Warn().Msg("script message JSON is empty")
187		return
188	}
189
190	var msg Message
191	if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil {
192		log.Warn().Err(err).Str("json", rawJSON).Msg("failed to unmarshal script message")
193		return
194	}
195	if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
196		msg.WebViewID = msg.WebViewIDAlt
197	}
198
199	if msg.Type == "" {
200		log.Warn().Msg("script message missing type")
201		return
202	}
203
204	entry, ok := r.getHandler(msg.Type)
205	if !ok || entry.handler == nil {
206		log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")
207		return
208	}
209
210	log.Info().
211		Str("type", msg.Type).
212		Uint64("webview_id", msg.WebViewID).
213		Int("payload_len", len(msg.Payload)).
214		Msg("received script message")
215
216	resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload)
217	if err != nil {
218		log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
219		if entry.errorCallback != "" {
220			if respErr := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.errorCallback, entry.world, err.Error()); respErr != nil {
221				log.Warn().Err(respErr).Msg("failed to dispatch error callback")
222			}
223		}
224		return
225	}
226
227	if entry.callback != "" {
228		if err := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.callback, entry.world, resp); err != nil {
229			log.Warn().Err(err).
230				Str("callback", entry.callback).
231				Uint64("webview_id", msg.WebViewID).
232				Msg("failed to dispatch callback response")
233		}
234	}
235}
236
237// getHandler retrieves a handler by message type.
238func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
239	r.mu.RLock()
240	defer r.mu.RUnlock()
241	entry, ok := r.handlers[msgType]
242	return entry, ok
243}
244
245// dispatchResponse serializes the payload and invokes a window callback in JS.
246func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewID, callback, world string, payload any) error {
247	if ctx == nil {
248		ctx = r.baseCtx
249	}
250
251	if callback == "" {
252		return errors.New("callback name is empty")
253	}
254
255	if webviewID == 0 {
256		return errors.New("webview id is required for JS callback dispatch")
257	}
258
259	wv := LookupWebView(webviewID)
260	if wv == nil {
261		return fmt.Errorf("webview %d not found", webviewID)
262	}
263
264	data, err := json.Marshal(payload)
265	if err != nil {
266		return fmt.Errorf("marshal callback payload: %w", err)
267	}
268
269	script := fmt.Sprintf(
270		`(function(){try{if(window.%[1]s){window.%[1]s(%[2]s);}`+
271			`else{console.warn("dumber callback missing: %[1]s");}}`+
272			`catch(e){console.error("dumber callback %[1]s failed", e);}})();`,
273		callback,
274		string(data),
275	)
276
277	wv.RunJavaScript(ctx, script, world)
278	return nil
279}

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