detached 88a2080

refactor(webkit): add content injection and script message routing infrastructure

Introduces ContentInjector for injecting scripts and styles into WebViews,
and MessageRouter for handling bidirectional JS<->Go communication via
WebKit's script message handler mechanism.
commit
88a20800e08027d1b7de2fbd328ae5e894f65e9d
author
brice · · 2025-12-11 18:59 UTC
committer
brice · · 2025-12-11 20:31 UTC
parent
6974549
signature
unsigned
2 files · +448 -0
A internal/infrastructure/webkit/injector.go +206 -0
@@ -0,0 +1,206 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "strings"
8+
9+ "github.com/bnema/dumber/assets"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/puregotk-webkit/webkit"
13+)
14+
15+const (
16+ // ScriptWorldName is the isolated world used for the injected UI.
17+ ScriptWorldName = "dumber"
18+ // MessageHandlerName is the name of the script message handler registered with WebKit.
19+ MessageHandlerName = "dumber"
20+)
21+
22+// ContentInjector encapsulates script and style injection into WebViews.
23+// It wraps WebKit's UserContentManager to load our frontend assets.
24+type ContentInjector struct {
25+ allowList []string
26+ blockList []string
27+ prefersDark bool
28+}
29+
30+// NewContentInjector creates a new injector instance with config-based dark mode detection.
31+func NewContentInjector(ctx context.Context, cfg *config.Config) *ContentInjector {
32+ ci := &ContentInjector{}
33+ ci.resolveColorScheme(ctx, cfg)
34+ return ci
35+}
36+
37+// resolveColorScheme determines dark mode preference from config or system.
38+func (ci *ContentInjector) resolveColorScheme(ctx context.Context, cfg *config.Config) {
39+ log := logging.FromContext(ctx)
40+
41+ if cfg != nil {
42+ switch cfg.Appearance.ColorScheme {
43+ case "prefer-dark", "dark":
44+ ci.prefersDark = true
45+ log.Debug().Bool("prefers_dark", true).Msg("color scheme from config: dark")
46+ return
47+ case "prefer-light", "light":
48+ ci.prefersDark = false
49+ log.Debug().Bool("prefers_dark", false).Msg("color scheme from config: light")
50+ return
51+ }
52+ }
53+
54+ // Fallback: detect from GTK_THEME environment variable
55+ gtkTheme := os.Getenv("GTK_THEME")
56+ ci.prefersDark = strings.Contains(strings.ToLower(gtkTheme), "dark")
57+ log.Debug().Bool("prefers_dark", ci.prefersDark).Str("gtk_theme", gtkTheme).Msg("color scheme from environment")
58+}
59+
60+// PrefersDark returns the current dark mode preference.
61+func (ci *ContentInjector) PrefersDark() bool {
62+ return ci.prefersDark
63+}
64+
65+// SetURLFilters configures optional allow/block lists for script/style injection.
66+func (ci *ContentInjector) SetURLFilters(allowList, blockList []string) {
67+ ci.allowList = allowList
68+ ci.blockList = blockList
69+}
70+
71+// InjectScripts adds the bootstrap and GUI scripts to the given content manager.
72+func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
73+ log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
74+
75+ if ucm == nil {
76+ log.Warn().Msg("cannot inject scripts: user content manager is nil")
77+ return
78+ }
79+
80+ addScript := func(script *webkit.UserScript, label string) {
81+ if script == nil {
82+ log.Warn().Str("script", label).Msg("failed to create user script")
83+ return
84+ }
85+ ucm.AddScript(script)
86+ log.Debug().Str("script", label).Msg("injected user script")
87+ }
88+
89+ // Inject GTK dark mode preference for color-scheme.js (must be before ColorSchemeScript)
90+ darkModeScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)
91+ addScript(
92+ webkit.NewUserScript(
93+ darkModeScript,
94+ webkit.UserContentInjectTopFrameValue,
95+ webkit.UserScriptInjectAtDocumentStartValue,
96+ nil,
97+ nil,
98+ ),
99+ "gtk-dark-mode",
100+ )
101+
102+ // Expose the WebView ID early so JS can tag outgoing messages.
103+ // Inject in both main world and isolated world since they have separate window objects.
104+ if webviewID != 0 {
105+ idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
106+ // Main world injection
107+ addScript(
108+ webkit.NewUserScript(
109+ idScript,
110+ webkit.UserContentInjectTopFrameValue,
111+ webkit.UserScriptInjectAtDocumentStartValue,
112+ nil,
113+ nil,
114+ ),
115+ "webview-id",
116+ )
117+ // Isolated world injection
118+ addScript(
119+ webkit.NewUserScriptForWorld(
120+ idScript,
121+ webkit.UserContentInjectTopFrameValue,
122+ webkit.UserScriptInjectAtDocumentStartValue,
123+ ScriptWorldName,
124+ nil,
125+ nil,
126+ ),
127+ "webview-id-isolated",
128+ )
129+ }
130+
131+ // Inject color-scheme handler (uses __dumber_gtk_prefers_dark)
132+ if assets.ColorSchemeScript != "" {
133+ addScript(
134+ webkit.NewUserScript(
135+ assets.ColorSchemeScript,
136+ webkit.UserContentInjectTopFrameValue,
137+ webkit.UserScriptInjectAtDocumentStartValue,
138+ nil,
139+ nil,
140+ ),
141+ "color-scheme",
142+ )
143+ }
144+
145+ if assets.MainWorldScript != "" {
146+ addScript(
147+ webkit.NewUserScript(
148+ assets.MainWorldScript,
149+ webkit.UserContentInjectTopFrameValue,
150+ webkit.UserScriptInjectAtDocumentStartValue,
151+ ci.allowList,
152+ ci.blockList,
153+ ),
154+ "main-world",
155+ )
156+ } else {
157+ log.Warn().Msg("MainWorldScript asset is empty; skipping injection")
158+ }
159+
160+ if assets.GUIScript != "" {
161+ addScript(
162+ webkit.NewUserScriptForWorld(
163+ assets.GUIScript,
164+ webkit.UserContentInjectTopFrameValue,
165+ webkit.UserScriptInjectAtDocumentEndValue,
166+ ScriptWorldName,
167+ ci.allowList,
168+ ci.blockList,
169+ ),
170+ "gui-world",
171+ )
172+ } else {
173+ log.Warn().Msg("GUIScript asset is empty; skipping injection")
174+ }
175+}
176+
177+// InjectStyles adds the component CSS to the isolated world.
178+func (ci *ContentInjector) InjectStyles(ctx context.Context, ucm *webkit.UserContentManager) {
179+ log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
180+
181+ if ucm == nil {
182+ log.Warn().Msg("cannot inject styles: user content manager is nil")
183+ return
184+ }
185+
186+ if assets.ComponentStyles == "" {
187+ log.Warn().Msg("ComponentStyles asset is empty; skipping injection")
188+ return
189+ }
190+
191+ style := webkit.NewUserStyleSheetForWorld(
192+ assets.ComponentStyles,
193+ webkit.UserContentInjectTopFrameValue,
194+ webkit.UserStyleLevelUserValue,
195+ ScriptWorldName,
196+ ci.allowList,
197+ ci.blockList,
198+ )
199+ if style == nil {
200+ log.Warn().Msg("failed to create user style sheet")
201+ return
202+ }
203+
204+ ucm.AddStyleSheet(style)
205+ log.Debug().Msg("injected component styles")
206+}
@@ -0,0 +1,206 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "strings"
8+
9+ "github.com/bnema/dumber/assets"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/puregotk-webkit/webkit"
13+)
14+
15+const (
16+ // ScriptWorldName is the isolated world used for the injected UI.
17+ ScriptWorldName = "dumber"
18+ // MessageHandlerName is the name of the script message handler registered with WebKit.
19+ MessageHandlerName = "dumber"
20+)
21+
22+// ContentInjector encapsulates script and style injection into WebViews.
23+// It wraps WebKit's UserContentManager to load our frontend assets.
24+type ContentInjector struct {
25+ allowList []string
26+ blockList []string
27+ prefersDark bool
28+}
29+
30+// NewContentInjector creates a new injector instance with config-based dark mode detection.
31+func NewContentInjector(ctx context.Context, cfg *config.Config) *ContentInjector {
32+ ci := &ContentInjector{}
33+ ci.resolveColorScheme(ctx, cfg)
34+ return ci
35+}
36+
37+// resolveColorScheme determines dark mode preference from config or system.
38+func (ci *ContentInjector) resolveColorScheme(ctx context.Context, cfg *config.Config) {
39+ log := logging.FromContext(ctx)
40+
41+ if cfg != nil {
42+ switch cfg.Appearance.ColorScheme {
43+ case "prefer-dark", "dark":
44+ ci.prefersDark = true
45+ log.Debug().Bool("prefers_dark", true).Msg("color scheme from config: dark")
46+ return
47+ case "prefer-light", "light":
48+ ci.prefersDark = false
49+ log.Debug().Bool("prefers_dark", false).Msg("color scheme from config: light")
50+ return
51+ }
52+ }
53+
54+ // Fallback: detect from GTK_THEME environment variable
55+ gtkTheme := os.Getenv("GTK_THEME")
56+ ci.prefersDark = strings.Contains(strings.ToLower(gtkTheme), "dark")
57+ log.Debug().Bool("prefers_dark", ci.prefersDark).Str("gtk_theme", gtkTheme).Msg("color scheme from environment")
58+}
59+
60+// PrefersDark returns the current dark mode preference.
61+func (ci *ContentInjector) PrefersDark() bool {
62+ return ci.prefersDark
63+}
64+
65+// SetURLFilters configures optional allow/block lists for script/style injection.
66+func (ci *ContentInjector) SetURLFilters(allowList, blockList []string) {
67+ ci.allowList = allowList
68+ ci.blockList = blockList
69+}
70+
71+// InjectScripts adds the bootstrap and GUI scripts to the given content manager.
72+func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
73+ log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
74+
75+ if ucm == nil {
76+ log.Warn().Msg("cannot inject scripts: user content manager is nil")
77+ return
78+ }
79+
80+ addScript := func(script *webkit.UserScript, label string) {
81+ if script == nil {
82+ log.Warn().Str("script", label).Msg("failed to create user script")
83+ return
84+ }
85+ ucm.AddScript(script)
86+ log.Debug().Str("script", label).Msg("injected user script")
87+ }
88+
89+ // Inject GTK dark mode preference for color-scheme.js (must be before ColorSchemeScript)
90+ darkModeScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)
91+ addScript(
92+ webkit.NewUserScript(
93+ darkModeScript,
94+ webkit.UserContentInjectTopFrameValue,
95+ webkit.UserScriptInjectAtDocumentStartValue,
96+ nil,
97+ nil,
98+ ),
99+ "gtk-dark-mode",
100+ )
101+
102+ // Expose the WebView ID early so JS can tag outgoing messages.
103+ // Inject in both main world and isolated world since they have separate window objects.
104+ if webviewID != 0 {
105+ idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
106+ // Main world injection
107+ addScript(
108+ webkit.NewUserScript(
109+ idScript,
110+ webkit.UserContentInjectTopFrameValue,
111+ webkit.UserScriptInjectAtDocumentStartValue,
112+ nil,
113+ nil,
114+ ),
115+ "webview-id",
116+ )
117+ // Isolated world injection
118+ addScript(
119+ webkit.NewUserScriptForWorld(
120+ idScript,
121+ webkit.UserContentInjectTopFrameValue,
122+ webkit.UserScriptInjectAtDocumentStartValue,
123+ ScriptWorldName,
124+ nil,
125+ nil,
126+ ),
127+ "webview-id-isolated",
128+ )
129+ }
130+
131+ // Inject color-scheme handler (uses __dumber_gtk_prefers_dark)
132+ if assets.ColorSchemeScript != "" {
133+ addScript(
134+ webkit.NewUserScript(
135+ assets.ColorSchemeScript,
136+ webkit.UserContentInjectTopFrameValue,
137+ webkit.UserScriptInjectAtDocumentStartValue,
138+ nil,
139+ nil,
140+ ),
141+ "color-scheme",
142+ )
143+ }
144+
145+ if assets.MainWorldScript != "" {
146+ addScript(
147+ webkit.NewUserScript(
148+ assets.MainWorldScript,
149+ webkit.UserContentInjectTopFrameValue,
150+ webkit.UserScriptInjectAtDocumentStartValue,
151+ ci.allowList,
152+ ci.blockList,
153+ ),
154+ "main-world",
155+ )
156+ } else {
157+ log.Warn().Msg("MainWorldScript asset is empty; skipping injection")
158+ }
159+
160+ if assets.GUIScript != "" {
161+ addScript(
162+ webkit.NewUserScriptForWorld(
163+ assets.GUIScript,
164+ webkit.UserContentInjectTopFrameValue,
165+ webkit.UserScriptInjectAtDocumentEndValue,
166+ ScriptWorldName,
167+ ci.allowList,
168+ ci.blockList,
169+ ),
170+ "gui-world",
171+ )
172+ } else {
173+ log.Warn().Msg("GUIScript asset is empty; skipping injection")
174+ }
175+}
176+
177+// InjectStyles adds the component CSS to the isolated world.
178+func (ci *ContentInjector) InjectStyles(ctx context.Context, ucm *webkit.UserContentManager) {
179+ log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
180+
181+ if ucm == nil {
182+ log.Warn().Msg("cannot inject styles: user content manager is nil")
183+ return
184+ }
185+
186+ if assets.ComponentStyles == "" {
187+ log.Warn().Msg("ComponentStyles asset is empty; skipping injection")
188+ return
189+ }
190+
191+ style := webkit.NewUserStyleSheetForWorld(
192+ assets.ComponentStyles,
193+ webkit.UserContentInjectTopFrameValue,
194+ webkit.UserStyleLevelUserValue,
195+ ScriptWorldName,
196+ ci.allowList,
197+ ci.blockList,
198+ )
199+ if style == nil {
200+ log.Warn().Msg("failed to create user style sheet")
201+ return
202+ }
203+
204+ ucm.AddStyleSheet(style)
205+ log.Debug().Msg("injected component styles")
206+}
A internal/infrastructure/webkit/messaging.go +242 -0
@@ -0,0 +1,242 @@
1+package webkit
2+
3+import (
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.
16+type 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.
24+type 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.
29+type MessageHandlerFunc func(ctx context.Context, webviewID WebViewID, payload json.RawMessage) (any, error)
30+
31+// Handle calls f(ctx, webviewID, payload).
32+func (f MessageHandlerFunc) Handle(ctx context.Context, webviewID WebViewID, payload json.RawMessage) (any, error) {
33+ return f(ctx, webviewID, payload)
34+}
35+
36+type 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.
44+type 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.
54+func 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.
66+func (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).
83+func (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 and connects the signal.
107+func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, worldName string) (uint32, error) {
108+ log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
109+
110+ if ucm == nil {
111+ return 0, errors.New("user content manager is nil")
112+ }
113+ if worldName == "" {
114+ worldName = ScriptWorldName
115+ }
116+
117+ if ok := ucm.RegisterScriptMessageHandler(MessageHandlerName, worldName); !ok {
118+ return 0, fmt.Errorf("failed to register script message handler %q in world %q", MessageHandlerName, worldName)
119+ }
120+
121+ cb := func(_ webkit.UserContentManager, valuePtr uintptr) {
122+ r.handleScriptMessage(valuePtr)
123+ }
124+
125+ r.mu.Lock()
126+ r.callbacks = append(r.callbacks, cb) // keep callback alive
127+ r.mu.Unlock()
128+
129+ signalID := ucm.ConnectScriptMessageReceived(&cb)
130+ r.mu.Lock()
131+ r.signals = append(r.signals, signalID)
132+ r.mu.Unlock()
133+
134+ log.Debug().
135+ Str("handler", MessageHandlerName).
136+ Str("world", worldName).
137+ Uint32("signal_id", signalID).
138+ Msg("script message handler connected")
139+
140+ return signalID, nil
141+}
142+
143+// handleScriptMessage decodes the JSC value and routes it to the correct handler.
144+func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
145+ log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
146+
147+ if valuePtr == 0 {
148+ log.Warn().Msg("received script message with nil value pointer")
149+ return
150+ }
151+
152+ jscValue := javascriptcore.ValueNewFromInternalPtr(valuePtr)
153+ if jscValue == nil {
154+ log.Warn().Msg("failed to wrap script message JSC value")
155+ return
156+ }
157+
158+ rawJSON := jscValue.ToJson(0)
159+ if rawJSON == "" {
160+ log.Warn().Msg("script message JSON is empty")
161+ return
162+ }
163+
164+ var msg Message
165+ if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil {
166+ log.Warn().Err(err).Str("json", rawJSON).Msg("failed to unmarshal script message")
167+ return
168+ }
169+ if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
170+ msg.WebViewID = msg.WebViewIDAlt
171+ }
172+
173+ if msg.Type == "" {
174+ log.Warn().Msg("script message missing type")
175+ return
176+ }
177+
178+ entry, ok := r.getHandler(msg.Type)
179+ if !ok || entry.handler == nil {
180+ log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")
181+ return
182+ }
183+
184+ resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload)
185+ if err != nil {
186+ log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
187+ if entry.errorCallback != "" {
188+ if respErr := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.errorCallback, entry.world, err.Error()); respErr != nil {
189+ log.Warn().Err(respErr).Msg("failed to dispatch error callback")
190+ }
191+ }
192+ return
193+ }
194+
195+ if entry.callback != "" {
196+ if err := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.callback, entry.world, resp); err != nil {
197+ log.Warn().Err(err).
198+ Str("callback", entry.callback).
199+ Uint64("webview_id", msg.WebViewID).
200+ Msg("failed to dispatch callback response")
201+ }
202+ }
203+}
204+
205+// getHandler retrieves a handler by message type.
206+func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
207+ r.mu.RLock()
208+ defer r.mu.RUnlock()
209+ entry, ok := r.handlers[msgType]
210+ return entry, ok
211+}
212+
213+// dispatchResponse serializes the payload and invokes a window callback in JS.
214+func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewID, callback, world string, payload any) error {
215+ if ctx == nil {
216+ ctx = r.baseCtx
217+ }
218+
219+ if callback == "" {
220+ return errors.New("callback name is empty")
221+ }
222+
223+ if webviewID == 0 {
224+ return errors.New("webview id is required for JS callback dispatch")
225+ }
226+
227+ wv := LookupWebView(webviewID)
228+ if wv == nil {
229+ return fmt.Errorf("webview %d not found", webviewID)
230+ }
231+
232+ data, err := json.Marshal(payload)
233+ if err != nil {
234+ return fmt.Errorf("marshal callback payload: %w", err)
235+ }
236+
237+ script := fmt.Sprintf(`(function(){try{if(window.%[1]s){window.%[1]s(%[2]s);}else{console.warn("dumber callback missing: %[1]s");}}catch(e){console.error("dumber callback %[1]s failed", e);}})();`, callback, string(data))
238+
239+ // Use async to avoid blocking the GTK main loop (signal handler context)
240+ wv.RunJavaScriptAsync(ctx, script, world)
241+ return nil
242+}
@@ -0,0 +1,242 @@
1+package webkit
2+
3+import (
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.
16+type 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.
24+type 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.
29+type MessageHandlerFunc func(ctx context.Context, webviewID WebViewID, payload json.RawMessage) (any, error)
30+
31+// Handle calls f(ctx, webviewID, payload).
32+func (f MessageHandlerFunc) Handle(ctx context.Context, webviewID WebViewID, payload json.RawMessage) (any, error) {
33+ return f(ctx, webviewID, payload)
34+}
35+
36+type 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.
44+type 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.
54+func 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.
66+func (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).
83+func (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 and connects the signal.
107+func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, worldName string) (uint32, error) {
108+ log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
109+
110+ if ucm == nil {
111+ return 0, errors.New("user content manager is nil")
112+ }
113+ if worldName == "" {
114+ worldName = ScriptWorldName
115+ }
116+
117+ if ok := ucm.RegisterScriptMessageHandler(MessageHandlerName, worldName); !ok {
118+ return 0, fmt.Errorf("failed to register script message handler %q in world %q", MessageHandlerName, worldName)
119+ }
120+
121+ cb := func(_ webkit.UserContentManager, valuePtr uintptr) {
122+ r.handleScriptMessage(valuePtr)
123+ }
124+
125+ r.mu.Lock()
126+ r.callbacks = append(r.callbacks, cb) // keep callback alive
127+ r.mu.Unlock()
128+
129+ signalID := ucm.ConnectScriptMessageReceived(&cb)
130+ r.mu.Lock()
131+ r.signals = append(r.signals, signalID)
132+ r.mu.Unlock()
133+
134+ log.Debug().
135+ Str("handler", MessageHandlerName).
136+ Str("world", worldName).
137+ Uint32("signal_id", signalID).
138+ Msg("script message handler connected")
139+
140+ return signalID, nil
141+}
142+
143+// handleScriptMessage decodes the JSC value and routes it to the correct handler.
144+func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
145+ log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
146+
147+ if valuePtr == 0 {
148+ log.Warn().Msg("received script message with nil value pointer")
149+ return
150+ }
151+
152+ jscValue := javascriptcore.ValueNewFromInternalPtr(valuePtr)
153+ if jscValue == nil {
154+ log.Warn().Msg("failed to wrap script message JSC value")
155+ return
156+ }
157+
158+ rawJSON := jscValue.ToJson(0)
159+ if rawJSON == "" {
160+ log.Warn().Msg("script message JSON is empty")
161+ return
162+ }
163+
164+ var msg Message
165+ if err := json.Unmarshal([]byte(rawJSON), &msg); err != nil {
166+ log.Warn().Err(err).Str("json", rawJSON).Msg("failed to unmarshal script message")
167+ return
168+ }
169+ if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
170+ msg.WebViewID = msg.WebViewIDAlt
171+ }
172+
173+ if msg.Type == "" {
174+ log.Warn().Msg("script message missing type")
175+ return
176+ }
177+
178+ entry, ok := r.getHandler(msg.Type)
179+ if !ok || entry.handler == nil {
180+ log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")
181+ return
182+ }
183+
184+ resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload)
185+ if err != nil {
186+ log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
187+ if entry.errorCallback != "" {
188+ if respErr := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.errorCallback, entry.world, err.Error()); respErr != nil {
189+ log.Warn().Err(respErr).Msg("failed to dispatch error callback")
190+ }
191+ }
192+ return
193+ }
194+
195+ if entry.callback != "" {
196+ if err := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.callback, entry.world, resp); err != nil {
197+ log.Warn().Err(err).
198+ Str("callback", entry.callback).
199+ Uint64("webview_id", msg.WebViewID).
200+ Msg("failed to dispatch callback response")
201+ }
202+ }
203+}
204+
205+// getHandler retrieves a handler by message type.
206+func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) {
207+ r.mu.RLock()
208+ defer r.mu.RUnlock()
209+ entry, ok := r.handlers[msgType]
210+ return entry, ok
211+}
212+
213+// dispatchResponse serializes the payload and invokes a window callback in JS.
214+func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewID, callback, world string, payload any) error {
215+ if ctx == nil {
216+ ctx = r.baseCtx
217+ }
218+
219+ if callback == "" {
220+ return errors.New("callback name is empty")
221+ }
222+
223+ if webviewID == 0 {
224+ return errors.New("webview id is required for JS callback dispatch")
225+ }
226+
227+ wv := LookupWebView(webviewID)
228+ if wv == nil {
229+ return fmt.Errorf("webview %d not found", webviewID)
230+ }
231+
232+ data, err := json.Marshal(payload)
233+ if err != nil {
234+ return fmt.Errorf("marshal callback payload: %w", err)
235+ }
236+
237+ script := fmt.Sprintf(`(function(){try{if(window.%[1]s){window.%[1]s(%[2]s);}else{console.warn("dumber callback missing: %[1]s");}}catch(e){console.error("dumber callback %[1]s failed", e);}})();`, callback, string(data))
238+
239+ // Use async to avoid blocking the GTK main loop (signal handler context)
240+ wv.RunJavaScriptAsync(ctx, script, world)
241+ return nil
242+}

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