v0.21.0 73bf644

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

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