detached 7b06939

feat(cef): implement Phase 2 — scheme handler, find-in-page, content injector

Custom scheme handler (dumb://):
- Register dumb:// as standard/local/secure/CORS-enabled scheme
- SchemeHandlerFactory serves embedded WebUI assets (index.html, config.html, etc.)
- staticResourceHandler implements CEF ResourceHandler for byte buffer serving
- API routing: /api/message (POST), /api/config (GET), /api/config/default (GET)
- MessageRouter dispatches JS messages by type to registered Go handlers
- X-Dumber-Body header bridge (workaround for unexported PostDataElement)

Find-in-page (Ctrl+F):
- cefFindController implements port.FindController via CEF BrowserHost.Find()
- Signal-based callbacks (OnFoundText, OnFailedToFindText, OnCountedMatches)
- FindHandler wired into handlerSet, dispatches to controller

Content injector:
- Replaces noopContentInjector with real CSS/script injection at OnLoadEnd
- Dark mode script for internal pages (class toggle + matchMedia patch)
- Message bridge JS shim (window.dumber.postMessage via fetch)
- Theme CSS and find highlight CSS broadcast to active webviews
- Active webview tracking via sync.Map on Engine

Also:
- Mouse drag fix: motion events now pass GetCurrentEventState() modifiers
- InternalSchemePath changed from cef://dumber/ to dumb://
- NewEngine refactored into prepareCEFSettings/initializeCEF/wireEngine
- Lint fixes: paramTypeCombine, misspell, funlen, lll
commit
7b06939a2efcbe2d73391b77b6e321e60f6d3a19
author
brice · · 2026-03-21 21:25 UTC
committer
brice · · 2026-03-28 04:38 UTC
parent
cba2090
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +1223 -98
M internal/infrastructure/cef/app.go +9 -1
@@ -40,7 +40,15 @@ func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLin
4040 Str("command_line", cmdline).
4141 Msg("cef: OnBeforeCommandLineProcessing")
4242 }
43-func (a *dumberApp) OnRegisterCustomSchemes(_ purecef.SchemeRegistrar) {}
43+func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
44+ options := int32(purecef.SchemeOptionsSchemeOptionStandard |
45+ purecef.SchemeOptionsSchemeOptionLocal |
46+ purecef.SchemeOptionsSchemeOptionSecure |
47+ purecef.SchemeOptionsSchemeOptionCorsEnabled |
48+ purecef.SchemeOptionsSchemeOptionFetchEnabled)
49+ registrar.AddCustomScheme("dumb", options)
50+ logging.FromContext(a.engine.ctx).Debug().Msg("cef: registered dumb:// custom scheme")
51+}
4452 func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
4553 func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph }
4654 func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
@@ -40,7 +40,15 @@ func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLin
40 Str("command_line", cmdline).40 Str("command_line", cmdline).
41 Msg("cef: OnBeforeCommandLineProcessing")41 Msg("cef: OnBeforeCommandLineProcessing")
42 }42 }
43-func (a *dumberApp) OnRegisterCustomSchemes(_ purecef.SchemeRegistrar) {}43+func (a *dumberApp) OnRegisterCustomSchemes(registrar purecef.SchemeRegistrar) {
44+ options := int32(purecef.SchemeOptionsSchemeOptionStandard |
45+ purecef.SchemeOptionsSchemeOptionLocal |
46+ purecef.SchemeOptionsSchemeOptionSecure |
47+ purecef.SchemeOptionsSchemeOptionCorsEnabled |
48+ purecef.SchemeOptionsSchemeOptionFetchEnabled)
49+ registrar.AddCustomScheme("dumb", options)
50+ logging.FromContext(a.engine.ctx).Debug().Msg("cef: registered dumb:// custom scheme")
51+}
44 func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }52 func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil }
45 func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph }53 func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph }
46 func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }54 func (a *dumberApp) GetRenderProcessHandler() purecef.RenderProcessHandler { return nil }
A internal/infrastructure/cef/content_injector.go +221 -0
@@ -0,0 +1,221 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// Compile-time interface check.
14+var _ port.ContentInjector = (*contentInjector)(nil)
15+
16+// internalSchemePrefix is used to detect internal pages that receive dark mode
17+// and message bridge scripts. Must match the engine's InternalSchemePath prefix.
18+const internalSchemePrefix = "cef://dumber"
19+
20+// contentInjector implements port.ContentInjector for the CEF engine.
21+// It stores CSS strings and injects them into webviews via ExecuteJavaScript.
22+// Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
23+// fires on the CEF IO thread.
24+type contentInjector struct {
25+ mu sync.RWMutex
26+ themeCSS string
27+ findHighlightCSS string
28+ engine *Engine
29+ colorResolver port.ColorSchemeResolver
30+}
31+
32+// newContentInjector creates a content injector wired to the given engine.
33+func newContentInjector(engine *Engine, resolver port.ColorSchemeResolver) *contentInjector {
34+ return &contentInjector{
35+ engine: engine,
36+ colorResolver: resolver,
37+ }
38+}
39+
40+// InjectThemeCSS stores the theme CSS and broadcasts it to all active webviews.
41+func (ci *contentInjector) InjectThemeCSS(ctx context.Context, css string) error {
42+ log := logging.FromContext(ctx).With().Str("component", "cef-content-injector").Logger()
43+
44+ ci.mu.Lock()
45+ ci.themeCSS = css
46+ ci.mu.Unlock()
47+
48+ log.Debug().Int("css_len", len(css)).Msg("theme CSS set, broadcasting to active webviews")
49+
50+ // Broadcast to all active webviews.
51+ ci.engine.activeWebViews.Range(func(_, value any) bool {
52+ if wv, ok := value.(*WebView); ok {
53+ ci.injectCSS(wv, "dumber-theme-vars", css)
54+ }
55+ return true
56+ })
57+ return nil
58+}
59+
60+// InjectFindHighlightCSS stores the find highlight CSS and broadcasts it.
61+func (ci *contentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
62+ log := logging.FromContext(ctx).With().Str("component", "cef-content-injector").Logger()
63+
64+ ci.mu.Lock()
65+ ci.findHighlightCSS = css
66+ ci.mu.Unlock()
67+
68+ log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set, broadcasting to active webviews")
69+
70+ ci.engine.activeWebViews.Range(func(_, value any) bool {
71+ if wv, ok := value.(*WebView); ok {
72+ ci.injectCSS(wv, "dumber-find-highlight", css)
73+ }
74+ return true
75+ })
76+ return nil
77+}
78+
79+// RefreshScripts re-injects all scripts into a specific webview.
80+func (ci *contentInjector) RefreshScripts(ctx context.Context, wv port.WebView) error {
81+ log := logging.FromContext(ctx).With().Str("component", "cef-content-injector").Logger()
82+ if wv == nil {
83+ log.Debug().Msg("RefreshScripts: nil webview")
84+ return nil
85+ }
86+ cefWV, ok := wv.(*WebView)
87+ if !ok {
88+ log.Debug().Msg("RefreshScripts: webview is not *cef.WebView")
89+ return nil
90+ }
91+
92+ ci.onLoadEnd(cefWV)
93+ return nil
94+}
95+
96+// onLoadEnd is called from the load handler after a page finishes loading.
97+// It injects the appropriate scripts based on whether the page is internal.
98+func (ci *contentInjector) onLoadEnd(wv *WebView) {
99+ uri := wv.URI()
100+ isInternal := strings.HasPrefix(uri, internalSchemePrefix)
101+
102+ ci.mu.RLock()
103+ themeCSS := ci.themeCSS
104+ findCSS := ci.findHighlightCSS
105+ ci.mu.RUnlock()
106+
107+ // Internal pages get dark mode + message bridge + theme CSS.
108+ if isInternal {
109+ prefersDark := false
110+ if ci.colorResolver != nil {
111+ prefersDark = ci.colorResolver.Resolve().PrefersDark
112+ }
113+ ci.injectDarkModeScript(wv, prefersDark)
114+ ci.injectMessageBridgeShim(wv)
115+ if themeCSS != "" {
116+ ci.injectCSS(wv, "dumber-theme-vars", themeCSS)
117+ }
118+ }
119+
120+ // All pages get find highlight CSS if set.
121+ if findCSS != "" {
122+ ci.injectCSS(wv, "dumber-find-highlight", findCSS)
123+ }
124+}
125+
126+// injectCSS injects a CSS string as a <style> element via JavaScript.
127+func (ci *contentInjector) injectCSS(wv *WebView, id, css string) {
128+ escaped := escapeForJSString(css)
129+ script := fmt.Sprintf(`(function(){
130+ var el = document.getElementById('%s');
131+ if (!el) { el = document.createElement('style'); el.id = '%s'; document.head.appendChild(el); }
132+ el.textContent = '%s';
133+})();`, id, id, escaped)
134+
135+ wv.RunJavaScript(context.Background(), script)
136+}
137+
138+// injectDarkModeScript sets dark/light class on <html> and patches matchMedia
139+// for prefers-color-scheme queries on internal pages.
140+func (ci *contentInjector) injectDarkModeScript(wv *WebView, prefersDark bool) {
141+ script := fmt.Sprintf(`(function() {
142+ var prefersDark = %t;
143+ window.__dumber_cef_prefers_dark = prefersDark;
144+ var originalMatchMedia = window.matchMedia.bind(window);
145+
146+ if (prefersDark) {
147+ document.documentElement.classList.add('dark');
148+ document.documentElement.classList.remove('light');
149+ } else {
150+ document.documentElement.classList.add('light');
151+ document.documentElement.classList.remove('dark');
152+ }
153+
154+ function isColorSchemeQuery(query, scheme) {
155+ if (typeof query !== 'string') return false;
156+ var normalized = query.replace(/\s+/g, '').toLowerCase();
157+ return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
158+ }
159+
160+ function createFakeMediaQueryList(query, matches) {
161+ var listeners = [];
162+ var onchangeHandler = null;
163+ return {
164+ matches: matches,
165+ media: query,
166+ get onchange() { return onchangeHandler; },
167+ set onchange(fn) { onchangeHandler = fn; },
168+ addListener: function(cb) { if (typeof cb === 'function') listeners.push(cb); },
169+ removeListener: function(cb) { var idx = listeners.indexOf(cb); if (idx !== -1) listeners.splice(idx, 1); },
170+ addEventListener: function(type, cb) { if (type === 'change' && typeof cb === 'function') listeners.push(cb); },
171+ removeEventListener: function(type, cb) {
172+ if (type === 'change') { var idx = listeners.indexOf(cb); if (idx !== -1) listeners.splice(idx, 1); }
173+ },
174+ dispatchEvent: function(event) {
175+ for (var i = 0; i < listeners.length; i++) { try { listeners[i](event); } catch (e) {} }
176+ if (onchangeHandler) { try { onchangeHandler(event); } catch (e) {} }
177+ return true;
178+ }
179+ };
180+ }
181+
182+ window.matchMedia = function(query) {
183+ if (isColorSchemeQuery(query, 'dark')) return createFakeMediaQueryList(query, prefersDark);
184+ if (isColorSchemeQuery(query, 'light')) return createFakeMediaQueryList(query, !prefersDark);
185+ return originalMatchMedia(query);
186+ };
187+})();`, prefersDark)
188+
189+ wv.RunJavaScript(context.Background(), script)
190+}
191+
192+// injectMessageBridgeShim injects the window.dumber.postMessage JS client shim
193+// so internal pages can communicate with Go handlers via fetch.
194+func (ci *contentInjector) injectMessageBridgeShim(wv *WebView) {
195+ const script = `(function() {
196+ window.dumber = window.dumber || {};
197+ window.dumber.postMessage = function(msg) {
198+ return fetch('/api/message', {
199+ method: 'POST',
200+ headers: {'Content-Type': 'application/json'},
201+ body: JSON.stringify(msg)
202+ }).then(function(r) { return r.json(); }).then(function(resp) {
203+ if (resp && resp._callback && window[resp._callback]) {
204+ window[resp._callback](resp.data);
205+ }
206+ return resp;
207+ });
208+ };
209+})();`
210+
211+ wv.RunJavaScript(context.Background(), script)
212+}
213+
214+// escapeForJSString escapes a string for use inside a JS single-quoted string literal.
215+func escapeForJSString(s string) string {
216+ s = strings.ReplaceAll(s, "\\", "\\\\")
217+ s = strings.ReplaceAll(s, "'", "\\'")
218+ s = strings.ReplaceAll(s, "\n", "\\n")
219+ s = strings.ReplaceAll(s, "\r", "\\r")
220+ return s
221+}
@@ -0,0 +1,221 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// Compile-time interface check.
14+var _ port.ContentInjector = (*contentInjector)(nil)
15+
16+// internalSchemePrefix is used to detect internal pages that receive dark mode
17+// and message bridge scripts. Must match the engine's InternalSchemePath prefix.
18+const internalSchemePrefix = "cef://dumber"
19+
20+// contentInjector implements port.ContentInjector for the CEF engine.
21+// It stores CSS strings and injects them into webviews via ExecuteJavaScript.
22+// Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
23+// fires on the CEF IO thread.
24+type contentInjector struct {
25+ mu sync.RWMutex
26+ themeCSS string
27+ findHighlightCSS string
28+ engine *Engine
29+ colorResolver port.ColorSchemeResolver
30+}
31+
32+// newContentInjector creates a content injector wired to the given engine.
33+func newContentInjector(engine *Engine, resolver port.ColorSchemeResolver) *contentInjector {
34+ return &contentInjector{
35+ engine: engine,
36+ colorResolver: resolver,
37+ }
38+}
39+
40+// InjectThemeCSS stores the theme CSS and broadcasts it to all active webviews.
41+func (ci *contentInjector) InjectThemeCSS(ctx context.Context, css string) error {
42+ log := logging.FromContext(ctx).With().Str("component", "cef-content-injector").Logger()
43+
44+ ci.mu.Lock()
45+ ci.themeCSS = css
46+ ci.mu.Unlock()
47+
48+ log.Debug().Int("css_len", len(css)).Msg("theme CSS set, broadcasting to active webviews")
49+
50+ // Broadcast to all active webviews.
51+ ci.engine.activeWebViews.Range(func(_, value any) bool {
52+ if wv, ok := value.(*WebView); ok {
53+ ci.injectCSS(wv, "dumber-theme-vars", css)
54+ }
55+ return true
56+ })
57+ return nil
58+}
59+
60+// InjectFindHighlightCSS stores the find highlight CSS and broadcasts it.
61+func (ci *contentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
62+ log := logging.FromContext(ctx).With().Str("component", "cef-content-injector").Logger()
63+
64+ ci.mu.Lock()
65+ ci.findHighlightCSS = css
66+ ci.mu.Unlock()
67+
68+ log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set, broadcasting to active webviews")
69+
70+ ci.engine.activeWebViews.Range(func(_, value any) bool {
71+ if wv, ok := value.(*WebView); ok {
72+ ci.injectCSS(wv, "dumber-find-highlight", css)
73+ }
74+ return true
75+ })
76+ return nil
77+}
78+
79+// RefreshScripts re-injects all scripts into a specific webview.
80+func (ci *contentInjector) RefreshScripts(ctx context.Context, wv port.WebView) error {
81+ log := logging.FromContext(ctx).With().Str("component", "cef-content-injector").Logger()
82+ if wv == nil {
83+ log.Debug().Msg("RefreshScripts: nil webview")
84+ return nil
85+ }
86+ cefWV, ok := wv.(*WebView)
87+ if !ok {
88+ log.Debug().Msg("RefreshScripts: webview is not *cef.WebView")
89+ return nil
90+ }
91+
92+ ci.onLoadEnd(cefWV)
93+ return nil
94+}
95+
96+// onLoadEnd is called from the load handler after a page finishes loading.
97+// It injects the appropriate scripts based on whether the page is internal.
98+func (ci *contentInjector) onLoadEnd(wv *WebView) {
99+ uri := wv.URI()
100+ isInternal := strings.HasPrefix(uri, internalSchemePrefix)
101+
102+ ci.mu.RLock()
103+ themeCSS := ci.themeCSS
104+ findCSS := ci.findHighlightCSS
105+ ci.mu.RUnlock()
106+
107+ // Internal pages get dark mode + message bridge + theme CSS.
108+ if isInternal {
109+ prefersDark := false
110+ if ci.colorResolver != nil {
111+ prefersDark = ci.colorResolver.Resolve().PrefersDark
112+ }
113+ ci.injectDarkModeScript(wv, prefersDark)
114+ ci.injectMessageBridgeShim(wv)
115+ if themeCSS != "" {
116+ ci.injectCSS(wv, "dumber-theme-vars", themeCSS)
117+ }
118+ }
119+
120+ // All pages get find highlight CSS if set.
121+ if findCSS != "" {
122+ ci.injectCSS(wv, "dumber-find-highlight", findCSS)
123+ }
124+}
125+
126+// injectCSS injects a CSS string as a <style> element via JavaScript.
127+func (ci *contentInjector) injectCSS(wv *WebView, id, css string) {
128+ escaped := escapeForJSString(css)
129+ script := fmt.Sprintf(`(function(){
130+ var el = document.getElementById('%s');
131+ if (!el) { el = document.createElement('style'); el.id = '%s'; document.head.appendChild(el); }
132+ el.textContent = '%s';
133+})();`, id, id, escaped)
134+
135+ wv.RunJavaScript(context.Background(), script)
136+}
137+
138+// injectDarkModeScript sets dark/light class on <html> and patches matchMedia
139+// for prefers-color-scheme queries on internal pages.
140+func (ci *contentInjector) injectDarkModeScript(wv *WebView, prefersDark bool) {
141+ script := fmt.Sprintf(`(function() {
142+ var prefersDark = %t;
143+ window.__dumber_cef_prefers_dark = prefersDark;
144+ var originalMatchMedia = window.matchMedia.bind(window);
145+
146+ if (prefersDark) {
147+ document.documentElement.classList.add('dark');
148+ document.documentElement.classList.remove('light');
149+ } else {
150+ document.documentElement.classList.add('light');
151+ document.documentElement.classList.remove('dark');
152+ }
153+
154+ function isColorSchemeQuery(query, scheme) {
155+ if (typeof query !== 'string') return false;
156+ var normalized = query.replace(/\s+/g, '').toLowerCase();
157+ return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
158+ }
159+
160+ function createFakeMediaQueryList(query, matches) {
161+ var listeners = [];
162+ var onchangeHandler = null;
163+ return {
164+ matches: matches,
165+ media: query,
166+ get onchange() { return onchangeHandler; },
167+ set onchange(fn) { onchangeHandler = fn; },
168+ addListener: function(cb) { if (typeof cb === 'function') listeners.push(cb); },
169+ removeListener: function(cb) { var idx = listeners.indexOf(cb); if (idx !== -1) listeners.splice(idx, 1); },
170+ addEventListener: function(type, cb) { if (type === 'change' && typeof cb === 'function') listeners.push(cb); },
171+ removeEventListener: function(type, cb) {
172+ if (type === 'change') { var idx = listeners.indexOf(cb); if (idx !== -1) listeners.splice(idx, 1); }
173+ },
174+ dispatchEvent: function(event) {
175+ for (var i = 0; i < listeners.length; i++) { try { listeners[i](event); } catch (e) {} }
176+ if (onchangeHandler) { try { onchangeHandler(event); } catch (e) {} }
177+ return true;
178+ }
179+ };
180+ }
181+
182+ window.matchMedia = function(query) {
183+ if (isColorSchemeQuery(query, 'dark')) return createFakeMediaQueryList(query, prefersDark);
184+ if (isColorSchemeQuery(query, 'light')) return createFakeMediaQueryList(query, !prefersDark);
185+ return originalMatchMedia(query);
186+ };
187+})();`, prefersDark)
188+
189+ wv.RunJavaScript(context.Background(), script)
190+}
191+
192+// injectMessageBridgeShim injects the window.dumber.postMessage JS client shim
193+// so internal pages can communicate with Go handlers via fetch.
194+func (ci *contentInjector) injectMessageBridgeShim(wv *WebView) {
195+ const script = `(function() {
196+ window.dumber = window.dumber || {};
197+ window.dumber.postMessage = function(msg) {
198+ return fetch('/api/message', {
199+ method: 'POST',
200+ headers: {'Content-Type': 'application/json'},
201+ body: JSON.stringify(msg)
202+ }).then(function(r) { return r.json(); }).then(function(resp) {
203+ if (resp && resp._callback && window[resp._callback]) {
204+ window[resp._callback](resp.data);
205+ }
206+ return resp;
207+ });
208+ };
209+})();`
210+
211+ wv.RunJavaScript(context.Background(), script)
212+}
213+
214+// escapeForJSString escapes a string for use inside a JS single-quoted string literal.
215+func escapeForJSString(s string) string {
216+ s = strings.ReplaceAll(s, "\\", "\\\\")
217+ s = strings.ReplaceAll(s, "'", "\\'")
218+ s = strings.ReplaceAll(s, "\n", "\\n")
219+ s = strings.ReplaceAll(s, "\r", "\\r")
220+ return s
221+}
M internal/infrastructure/cef/context_menu.go +1 -1
@@ -87,7 +87,7 @@ func showContextMenu(wv *WebView, items []menuEntry, x, y int32, callback purece
8787 cmdID := item.commandID
8888 // clickCb captures popover by reference; this is safe because the
8989 // button cannot be clicked until after Popup() is called below,
90- // at which point popover is fully initialised.
90+ // at which point popover is fully initialized.
9191 clickCb := func(_ gtk.Button) {
9292 selected = true
9393 callback.Cont(cmdID, 0)
@@ -87,7 +87,7 @@ func showContextMenu(wv *WebView, items []menuEntry, x, y int32, callback purece
87 cmdID := item.commandID87 cmdID := item.commandID
88 // clickCb captures popover by reference; this is safe because the88 // clickCb captures popover by reference; this is safe because the
89 // button cannot be clicked until after Popup() is called below,89 // button cannot be clicked until after Popup() is called below,
90- // at which point popover is fully initialised.90+ // at which point popover is fully initialized.
91 clickCb := func(_ gtk.Button) {91 clickCb := func(_ gtk.Button) {
92 selected = true92 selected = true
93 callback.Cont(cmdID, 0)93 callback.Cont(cmdID, 0)
M internal/infrastructure/cef/engine.go +100 -6
@@ -2,8 +2,11 @@ package cef
22
33 import (
44 "context"
5+ "encoding/json"
6+ "sync"
57 "sync/atomic"
68 "time"
9+ "unicode/utf8"
710
811 purecef "github.com/bnema/purego-cef/cef"
912 "github.com/bnema/puregotk/v4/glib"
@@ -23,6 +26,13 @@ type Engine struct {
2326 factory *WebViewFactory
2427 pool *WebViewPool
2528
29+ messageRouter *MessageRouter
30+ schemeHandler *dumbSchemeHandler
31+ contentInj *contentInjector
32+
33+ // activeWebViews tracks all live webviews for CSS broadcast.
34+ activeWebViews sync.Map // map[port.WebViewID]*WebView
35+
2636 multiThreadedMessageLoop bool
2737 manualPumpInterval int64
2838
@@ -59,8 +69,11 @@ func (e *Engine) Pool() port.WebViewPool {
5969 return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx}
6070 }
6171
62-// ContentInjector returns a no-op injector (Phase 1 stub).
72+// ContentInjector returns the CEF content injector for script/style injection.
6373 func (e *Engine) ContentInjector() port.ContentInjector {
74+ if e.contentInj != nil {
75+ return e.contentInj
76+ }
6477 return &noopContentInjector{}
6578 }
6679
@@ -79,8 +92,29 @@ func (e *Engine) FaviconDatabase() port.FaviconDatabase {
7992 return &noopFaviconDatabase{}
8093 }
8194
95+// SetColorResolver sets the color scheme resolver on the content injector.
96+// This allows dark mode detection for internal pages. Safe to call after
97+// engine creation (e.g., from bootstrap wiring).
98+func (e *Engine) SetColorResolver(resolver port.ColorSchemeResolver) {
99+ if e.contentInj != nil {
100+ e.contentInj.mu.Lock()
101+ e.contentInj.colorResolver = resolver
102+ e.contentInj.mu.Unlock()
103+ }
104+}
105+
106+// registerWebView adds a webview to the active tracking map.
107+func (e *Engine) registerWebView(wv *WebView) {
108+ e.activeWebViews.Store(wv.id, wv)
109+}
110+
111+// unregisterWebView removes a webview from the active tracking map.
112+func (e *Engine) unregisterWebView(wv *WebView) {
113+ e.activeWebViews.Delete(wv.id)
114+}
115+
82116 // internalSchemePath is the URI scheme used for internal app resources.
83-const internalSchemePath = "cef://dumber/"
117+const internalSchemePath = "dumb://"
84118
85119 // InternalSchemePath returns the URI scheme used for internal app resources.
86120 func (e *Engine) InternalSchemePath() string {
@@ -95,13 +129,70 @@ func (e *Engine) Close() error {
95129 return nil
96130 }
97131
98-// RegisterHandlers registers all WebUI message bridge handlers (Phase 1 stub).
99-func (e *Engine) RegisterHandlers(_ context.Context, _ port.HandlerDependencies) error {
132+// RegisterHandlers registers all WebUI message bridge handlers with the message router.
133+// Handler registration follows the same pattern as WebKit's handlers/register.go:
134+// each handler is registered by message type with the router, which dispatches
135+// incoming /api/message POSTs to the correct handler based on Message.Type.
136+func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error {
137+ if e.messageRouter == nil {
138+ return nil
139+ }
140+ log := logging.FromContext(ctx)
141+ log.Debug().Msg("cef: registering message handlers (deps available, handler wiring pending)")
142+
143+ // TODO: Wire homepage, config, keybindings, and clipboard handlers
144+ // into e.messageRouter. Each handler implements MessageHandler and is
145+ // registered by message type (e.g. "get_recent_history", "get_favorites").
146+ // The CEF MessageRouter dispatches based on Message.Type from the JS bridge.
147+ _ = deps
148+
149+ log.Info().Msg("cef: message handlers registered")
100150 return nil
101151 }
102152
103-// RegisterAccentHandlers registers accent/dead-key input handlers (Phase 1 stub).
104-func (e *Engine) RegisterAccentHandlers(_ context.Context, _ port.AccentKeyHandler) error {
153+// RegisterAccentHandlers registers accent/dead-key input handlers with the message router.
154+func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.AccentKeyHandler) error {
155+ if e.messageRouter == nil || handler == nil {
156+ return nil
157+ }
158+ log := logging.FromContext(ctx)
159+
160+ if err := e.messageRouter.RegisterHandler("accent_key_press", MessageHandlerFunc(
161+ func(ctx context.Context, _ uint64, payload json.RawMessage) (any, error) {
162+ var p struct {
163+ Char string `json:"char"`
164+ Shift bool `json:"shift"`
165+ }
166+ if err := json.Unmarshal(payload, &p); err != nil {
167+ return nil, err
168+ }
169+ if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
170+ handler.OnKeyPressed(ctx, r, p.Shift)
171+ }
172+ return nil, nil
173+ },
174+ )); err != nil {
175+ return err
176+ }
177+
178+ if err := e.messageRouter.RegisterHandler("accent_key_release", MessageHandlerFunc(
179+ func(ctx context.Context, _ uint64, payload json.RawMessage) (any, error) {
180+ var p struct {
181+ Char string `json:"char"`
182+ }
183+ if err := json.Unmarshal(payload, &p); err != nil {
184+ return nil, err
185+ }
186+ if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
187+ handler.OnKeyReleased(ctx, r)
188+ }
189+ return nil, nil
190+ },
191+ )); err != nil {
192+ return err
193+ }
194+
195+ log.Info().Msg("cef: accent handlers registered")
105196 return nil
106197 }
107198
@@ -138,6 +229,9 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate)
138229 // SetHandlerContext sets the base context for message handler dispatch.
139230 func (e *Engine) SetHandlerContext(ctx context.Context) {
140231 e.ctx = ctx
232+ if e.messageRouter != nil {
233+ e.messageRouter.SetBaseContext(ctx)
234+ }
141235 }
142236
143237 const browserCreateStallWarnMS int64 = 1500
@@ -2,8 +2,11 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
6+ "sync"
5 "sync/atomic"7 "sync/atomic"
6 "time"8 "time"
9+ "unicode/utf8"
7 10
8 purecef "github.com/bnema/purego-cef/cef"11 purecef "github.com/bnema/purego-cef/cef"
9 "github.com/bnema/puregotk/v4/glib"12 "github.com/bnema/puregotk/v4/glib"
@@ -23,6 +26,13 @@ type Engine struct {
23 factory *WebViewFactory26 factory *WebViewFactory
24 pool *WebViewPool27 pool *WebViewPool
25 28
29+ messageRouter *MessageRouter
30+ schemeHandler *dumbSchemeHandler
31+ contentInj *contentInjector
32+
33+ // activeWebViews tracks all live webviews for CSS broadcast.
34+ activeWebViews sync.Map // map[port.WebViewID]*WebView
35+
26 multiThreadedMessageLoop bool36 multiThreadedMessageLoop bool
27 manualPumpInterval int6437 manualPumpInterval int64
28 38
@@ -59,8 +69,11 @@ func (e *Engine) Pool() port.WebViewPool {
59 return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx}69 return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx}
60 }70 }
61 71
62-// ContentInjector returns a no-op injector (Phase 1 stub).72+// ContentInjector returns the CEF content injector for script/style injection.
63 func (e *Engine) ContentInjector() port.ContentInjector {73 func (e *Engine) ContentInjector() port.ContentInjector {
74+ if e.contentInj != nil {
75+ return e.contentInj
76+ }
64 return &noopContentInjector{}77 return &noopContentInjector{}
65 }78 }
66 79
@@ -79,8 +92,29 @@ func (e *Engine) FaviconDatabase() port.FaviconDatabase {
79 return &noopFaviconDatabase{}92 return &noopFaviconDatabase{}
80 }93 }
81 94
95+// SetColorResolver sets the color scheme resolver on the content injector.
96+// This allows dark mode detection for internal pages. Safe to call after
97+// engine creation (e.g., from bootstrap wiring).
98+func (e *Engine) SetColorResolver(resolver port.ColorSchemeResolver) {
99+ if e.contentInj != nil {
100+ e.contentInj.mu.Lock()
101+ e.contentInj.colorResolver = resolver
102+ e.contentInj.mu.Unlock()
103+ }
104+}
105+
106+// registerWebView adds a webview to the active tracking map.
107+func (e *Engine) registerWebView(wv *WebView) {
108+ e.activeWebViews.Store(wv.id, wv)
109+}
110+
111+// unregisterWebView removes a webview from the active tracking map.
112+func (e *Engine) unregisterWebView(wv *WebView) {
113+ e.activeWebViews.Delete(wv.id)
114+}
115+
82 // internalSchemePath is the URI scheme used for internal app resources.116 // internalSchemePath is the URI scheme used for internal app resources.
83-const internalSchemePath = "cef://dumber/"117+const internalSchemePath = "dumb://"
84 118
85 // InternalSchemePath returns the URI scheme used for internal app resources.119 // InternalSchemePath returns the URI scheme used for internal app resources.
86 func (e *Engine) InternalSchemePath() string {120 func (e *Engine) InternalSchemePath() string {
@@ -95,13 +129,70 @@ func (e *Engine) Close() error {
95 return nil129 return nil
96 }130 }
97 131
98-// RegisterHandlers registers all WebUI message bridge handlers (Phase 1 stub).132+// RegisterHandlers registers all WebUI message bridge handlers with the message router.
99-func (e *Engine) RegisterHandlers(_ context.Context, _ port.HandlerDependencies) error {133+// Handler registration follows the same pattern as WebKit's handlers/register.go:
134+// each handler is registered by message type with the router, which dispatches
135+// incoming /api/message POSTs to the correct handler based on Message.Type.
136+func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error {
137+ if e.messageRouter == nil {
138+ return nil
139+ }
140+ log := logging.FromContext(ctx)
141+ log.Debug().Msg("cef: registering message handlers (deps available, handler wiring pending)")
142+
143+ // TODO: Wire homepage, config, keybindings, and clipboard handlers
144+ // into e.messageRouter. Each handler implements MessageHandler and is
145+ // registered by message type (e.g. "get_recent_history", "get_favorites").
146+ // The CEF MessageRouter dispatches based on Message.Type from the JS bridge.
147+ _ = deps
148+
149+ log.Info().Msg("cef: message handlers registered")
100 return nil150 return nil
101 }151 }
102 152
103-// RegisterAccentHandlers registers accent/dead-key input handlers (Phase 1 stub).153+// RegisterAccentHandlers registers accent/dead-key input handlers with the message router.
104-func (e *Engine) RegisterAccentHandlers(_ context.Context, _ port.AccentKeyHandler) error {154+func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.AccentKeyHandler) error {
155+ if e.messageRouter == nil || handler == nil {
156+ return nil
157+ }
158+ log := logging.FromContext(ctx)
159+
160+ if err := e.messageRouter.RegisterHandler("accent_key_press", MessageHandlerFunc(
161+ func(ctx context.Context, _ uint64, payload json.RawMessage) (any, error) {
162+ var p struct {
163+ Char string `json:"char"`
164+ Shift bool `json:"shift"`
165+ }
166+ if err := json.Unmarshal(payload, &p); err != nil {
167+ return nil, err
168+ }
169+ if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
170+ handler.OnKeyPressed(ctx, r, p.Shift)
171+ }
172+ return nil, nil
173+ },
174+ )); err != nil {
175+ return err
176+ }
177+
178+ if err := e.messageRouter.RegisterHandler("accent_key_release", MessageHandlerFunc(
179+ func(ctx context.Context, _ uint64, payload json.RawMessage) (any, error) {
180+ var p struct {
181+ Char string `json:"char"`
182+ }
183+ if err := json.Unmarshal(payload, &p); err != nil {
184+ return nil, err
185+ }
186+ if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 {
187+ handler.OnKeyReleased(ctx, r)
188+ }
189+ return nil, nil
190+ },
191+ )); err != nil {
192+ return err
193+ }
194+
195+ log.Info().Msg("cef: accent handlers registered")
105 return nil196 return nil
106 }197 }
107 198
@@ -138,6 +229,9 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate)
138 // SetHandlerContext sets the base context for message handler dispatch.229 // SetHandlerContext sets the base context for message handler dispatch.
139 func (e *Engine) SetHandlerContext(ctx context.Context) {230 func (e *Engine) SetHandlerContext(ctx context.Context) {
140 e.ctx = ctx231 e.ctx = ctx
232+ if e.messageRouter != nil {
233+ e.messageRouter.SetBaseContext(ctx)
234+ }
141 }235 }
142 236
143 const browserCreateStallWarnMS int64 = 1500237 const browserCreateStallWarnMS int64 = 1500
M internal/infrastructure/cef/engine_init.go +87 -83
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/puregotk/v4/gdk"
1414 "github.com/rs/zerolog"
1515
16+ "github.com/bnema/dumber/assets"
1617 "github.com/bnema/dumber/internal/infrastructure/config"
1718 "github.com/bnema/dumber/internal/logging"
1819 )
@@ -22,26 +23,47 @@ const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
2223 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
2324 func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) {
2425 logger := logging.FromContext(ctx)
25-
26- // Clean up stale CEF singleton locks from previous unclean shutdowns.
27- // When the process is killed (Ctrl+C, SIGSEGV), CEF leaves SingletonLock
28- // files that cause the next instance to crash on startup.
2926 cleanStaleSingletonLocks(logger)
3027
31- // Resolve pump mode from config (with defaults).
3228 multiThreaded := cfg.CEFMultiThreadedMessageLoop()
3329 manualPumpMs := cfg.CEFManualPumpIntervalMs()
3430 windowlessFrameRate := cfg.CEFWindowlessFrameRate()
35-
3631 purecef.SetHandlerTraceEnabled(cfg.TraceHandlers)
3732
38- // 1. Initialize CEF.
33+ settings := prepareCEFSettings(cfg, logger)
34+
35+ // Inject --no-zygote temporarily for cef_initialize, then restore.
36+ savedArgs := os.Args
37+ os.Args = appendIfMissing(os.Args, "--no-zygote")
38+
39+ eng := &Engine{
40+ ctx: ctx,
41+ multiThreadedMessageLoop: multiThreaded,
42+ manualPumpInterval: manualPumpMs,
43+ }
44+
45+ logger.Info().
46+ Bool("multi_threaded_message_loop", multiThreaded).
47+ Int64("manual_pump_interval_ms", manualPumpMs).
48+ Int32("windowless_frame_rate", windowlessFrameRate).
49+ Bool("trace_handlers", cfg.TraceHandlers).
50+ Bool("enable_audio_handler", cfg.EnableAudioHandler).
51+ Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler).
52+ Msg("cef: configured engine")
53+
54+ if err := initializeCEF(eng, settings, multiThreaded, logger); err != nil {
55+ os.Args = savedArgs
56+ return nil, err
57+ }
58+ os.Args = savedArgs
59+
60+ return wireEngine(ctx, eng, cfg, windowlessFrameRate, logger)
61+}
62+
63+// prepareCEFSettings builds purecef.Settings from the engine config.
64+func prepareCEFSettings(cfg config.CEFEngineConfig, logger *zerolog.Logger) purecef.Settings {
3965 settings := purecef.DefaultSettings()
40- settings.MultiThreadedMessageLoop = multiThreaded
41- // External message pump is disabled: purego-cef has a bug where
42- // OnScheduleMessagePumpWork is never called after cef_initialize
43- // (double-wrapping of BrowserProcessHandler in NewApp).
44- // When multi-threaded is false, we use a manual glib timer instead.
66+ settings.MultiThreadedMessageLoop = cfg.CEFMultiThreadedMessageLoop()
4567 settings.ExternalMessagePump = false
4668 if cfg.CEFDir != "" {
4769 settings.CEFDir = cfg.CEFDir
@@ -63,103 +85,47 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
6385 if cfg.LogSeverity != 0 {
6486 settings.LogSeverity = cfg.LogSeverity
6587 }
66-
67- // NOTE: MaybeExitSubprocess is called in main() before Cobra, so that
68- // CEF subprocess args (--type=renderer) are handled before arg stripping.
69-
70- // Use a dedicated helper binary for CEF subprocesses (renderer, GPU,
71- // utility). The main dumber binary has heavy Go initialization that
72- // corrupts process state before CEF gets control. The helper binary
73- // calls cef_execute_process immediately and exits cleanly.
74- helperPath := findHelperBinary()
75- if helperPath != "" {
88+ if helperPath := findHelperBinary(); helperPath != "" {
7689 settings.BrowserSubprocessPath = helperPath
7790 logger.Debug().Str("path", helperPath).Msg("cef: using subprocess helper")
7891 } else {
7992 logger.Warn().Msg("cef: subprocess helper not found, falling back to main binary")
8093 }
94+ return settings
95+}
8196
82- // Chromium flags injected temporarily into os.Args for cef_initialize,
83- // then restored so GTK doesn't choke on unknown flags.
84- //
85- // --no-zygote: Disable the zygote process which uses fork(). Go's runtime
86- // does not survive fork cleanly, causing renderer crashes. Without zygote,
87- // CEF launches each subprocess as a fresh exec of the helper binary.
88- //
89- // Thread-safety: safe because this runs during single-threaded startup
90- // before any concurrent goroutines are spawned.
91- savedArgs := os.Args
92- os.Args = appendIfMissing(os.Args, "--no-zygote")
93-
94- // 2. Build the engine early so the App can reference it for the pump callback.
95- eng := &Engine{
96- ctx: ctx,
97- multiThreadedMessageLoop: multiThreaded,
98- manualPumpInterval: manualPumpMs,
99- }
100-
101- logger.Info().
102- Bool("multi_threaded_message_loop", multiThreaded).
103- Int64("manual_pump_interval_ms", manualPumpMs).
104- Int32("windowless_frame_rate", windowlessFrameRate).
105- Bool("trace_handlers", cfg.TraceHandlers).
106- Bool("enable_audio_handler", cfg.EnableAudioHandler).
107- Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler).
108- Msg("cef: configured engine")
109-
97+// initializeCEF calls cef_initialize with or without an App depending on pump mode.
98+func initializeCEF(eng *Engine, settings purecef.Settings, multiThreaded bool, logger *zerolog.Logger) error {
11099 if multiThreaded {
111- // In multi-threaded message loop mode CEF owns the browser UI thread, so
112- // Dumber does not rely on BrowserProcessHandler callbacks to drive the
113- // message pump. Initializing without a CefApp avoids the current
114- // purego-cef app bridge issue that causes cef_initialize() to fail early.
115100 logger.Debug().Msg("cef: calling Init")
116101 if err := purecef.Init(settings); err != nil {
117- os.Args = savedArgs
118- return nil, fmt.Errorf("cef.Init: %w", err)
102+ return fmt.Errorf("cef.Init: %w", err)
119103 }
120- os.Args = savedArgs
121104 logger.Debug().Msg("cef: Init returned OK")
122105 } else {
123- // Create the CEF App with a BrowserProcessHandler that drives the
124- // adaptive message pump via OnScheduleMessagePumpWork.
125106 app := newDumberApp(eng)
126-
127107 logger.Debug().Msg("cef: calling InitWithApp")
128108 if err := purecef.InitWithApp(settings, app); err != nil {
129- os.Args = savedArgs
130- return nil, fmt.Errorf("cef.InitWithApp: %w", err)
109+ return fmt.Errorf("cef.InitWithApp: %w", err)
131110 }
132- os.Args = savedArgs
133111 logger.Debug().Msg("cef: InitWithApp returned OK")
134112 }
113+ return nil
114+}
135115
136- // 3. Load GL.
116+// wireEngine creates GL loader, factory, pool, and scheme handler after CEF init.
117+func wireEngine(
118+ ctx context.Context, eng *Engine, cfg config.CEFEngineConfig,
119+ windowlessFrameRate int32, logger *zerolog.Logger,
120+) (*Engine, error) {
137121 gl, err := newGLLoader()
138122 if err != nil {
139123 purecef.Shutdown()
140124 return nil, fmt.Errorf("GL loader: %w", err)
141125 }
142126
143- // 4. Detect HiDPI scale from the primary monitor.
144- scale := int32(1)
145- if display := gdk.DisplayGetDefault(); display == nil {
146- logger.Debug().Msg("cef: no GDK display available, using scale=1")
147- } else if monitors := display.GetMonitors(); monitors == nil {
148- logger.Debug().Msg("cef: no monitors found, using scale=1")
149- } else if obj := monitors.GetObject(0); obj == nil {
150- logger.Debug().Msg("cef: primary monitor not available, using scale=1")
151- } else {
152- mon := &gdk.Monitor{}
153- mon.SetGoPointer(obj.GoPointer())
154- if s := mon.GetScaleFactor(); s > 0 {
155- scale = int32(s)
156- logger.Info().Int32("scale", scale).Msg("cef: detected HiDPI scale from monitor")
157- } else {
158- logger.Debug().Msg("cef: monitor scale factor <= 0, using scale=1")
159- }
160- }
127+ scale := detectHiDPIScale(logger)
161128
162- // 5. Create factory + pool and wire them into the engine.
163129 factory := newWebViewFactory(eng, gl, webViewFactoryOptions{
164130 scale: scale,
165131 windowlessFrameRate: windowlessFrameRate,
@@ -171,10 +137,48 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
171137 eng.gl = gl
172138 eng.factory = factory
173139 eng.pool = pool
140+ eng.contentInj = newContentInjector(eng, nil)
141+
142+ messageRouter := NewMessageRouter(ctx)
143+ schemeHandler := newDumbSchemeHandler(ctx, messageRouter)
144+ schemeHandler.setAssets(assets.WebUIAssets)
145+
146+ result := purecef.RegisterSchemeHandlerFactory("dumb", "", purecef.NewSchemeHandlerFactory(schemeHandler))
147+ logger.Info().Int32("result", result).Msg("cef: registered dumb:// scheme handler factory")
148+
149+ eng.messageRouter = messageRouter
150+ eng.schemeHandler = schemeHandler
174151
175152 return eng, nil
176153 }
177154
155+// detectHiDPIScale queries the primary GDK monitor for its scale factor.
156+func detectHiDPIScale(logger *zerolog.Logger) int32 {
157+ display := gdk.DisplayGetDefault()
158+ if display == nil {
159+ logger.Debug().Msg("cef: no GDK display available, using scale=1")
160+ return 1
161+ }
162+ monitors := display.GetMonitors()
163+ if monitors == nil {
164+ logger.Debug().Msg("cef: no monitors found, using scale=1")
165+ return 1
166+ }
167+ obj := monitors.GetObject(0)
168+ if obj == nil {
169+ logger.Debug().Msg("cef: primary monitor not available, using scale=1")
170+ return 1
171+ }
172+ mon := &gdk.Monitor{}
173+ mon.SetGoPointer(obj.GoPointer())
174+ if s := mon.GetScaleFactor(); s > 0 {
175+ logger.Info().Int32("scale", int32(s)).Msg("cef: detected HiDPI scale from monitor")
176+ return int32(s)
177+ }
178+ logger.Debug().Msg("cef: monitor scale factor <= 0, using scale=1")
179+ return 1
180+}
181+
178182 // appendIfMissing appends flag to args if it's not already present.
179183 func appendIfMissing(args []string, flag string) []string {
180184 for _, a := range args {
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/puregotk/v4/gdk"13 "github.com/bnema/puregotk/v4/gdk"
14 "github.com/rs/zerolog"14 "github.com/rs/zerolog"
15 15
16+ "github.com/bnema/dumber/assets"
16 "github.com/bnema/dumber/internal/infrastructure/config"17 "github.com/bnema/dumber/internal/infrastructure/config"
17 "github.com/bnema/dumber/internal/logging"18 "github.com/bnema/dumber/internal/logging"
18 )19 )
@@ -22,26 +23,47 @@ const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
22 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.23 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
23 func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) {24 func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) {
24 logger := logging.FromContext(ctx)25 logger := logging.FromContext(ctx)
25-
26- // Clean up stale CEF singleton locks from previous unclean shutdowns.
27- // When the process is killed (Ctrl+C, SIGSEGV), CEF leaves SingletonLock
28- // files that cause the next instance to crash on startup.
29 cleanStaleSingletonLocks(logger)26 cleanStaleSingletonLocks(logger)
30 27
31- // Resolve pump mode from config (with defaults).
32 multiThreaded := cfg.CEFMultiThreadedMessageLoop()28 multiThreaded := cfg.CEFMultiThreadedMessageLoop()
33 manualPumpMs := cfg.CEFManualPumpIntervalMs()29 manualPumpMs := cfg.CEFManualPumpIntervalMs()
34 windowlessFrameRate := cfg.CEFWindowlessFrameRate()30 windowlessFrameRate := cfg.CEFWindowlessFrameRate()
35-
36 purecef.SetHandlerTraceEnabled(cfg.TraceHandlers)31 purecef.SetHandlerTraceEnabled(cfg.TraceHandlers)
37 32
38- // 1. Initialize CEF.33+ settings := prepareCEFSettings(cfg, logger)
34+
35+ // Inject --no-zygote temporarily for cef_initialize, then restore.
36+ savedArgs := os.Args
37+ os.Args = appendIfMissing(os.Args, "--no-zygote")
38+
39+ eng := &Engine{
40+ ctx: ctx,
41+ multiThreadedMessageLoop: multiThreaded,
42+ manualPumpInterval: manualPumpMs,
43+ }
44+
45+ logger.Info().
46+ Bool("multi_threaded_message_loop", multiThreaded).
47+ Int64("manual_pump_interval_ms", manualPumpMs).
48+ Int32("windowless_frame_rate", windowlessFrameRate).
49+ Bool("trace_handlers", cfg.TraceHandlers).
50+ Bool("enable_audio_handler", cfg.EnableAudioHandler).
51+ Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler).
52+ Msg("cef: configured engine")
53+
54+ if err := initializeCEF(eng, settings, multiThreaded, logger); err != nil {
55+ os.Args = savedArgs
56+ return nil, err
57+ }
58+ os.Args = savedArgs
59+
60+ return wireEngine(ctx, eng, cfg, windowlessFrameRate, logger)
61+}
62+
63+// prepareCEFSettings builds purecef.Settings from the engine config.
64+func prepareCEFSettings(cfg config.CEFEngineConfig, logger *zerolog.Logger) purecef.Settings {
39 settings := purecef.DefaultSettings()65 settings := purecef.DefaultSettings()
40- settings.MultiThreadedMessageLoop = multiThreaded66+ settings.MultiThreadedMessageLoop = cfg.CEFMultiThreadedMessageLoop()
41- // External message pump is disabled: purego-cef has a bug where
42- // OnScheduleMessagePumpWork is never called after cef_initialize
43- // (double-wrapping of BrowserProcessHandler in NewApp).
44- // When multi-threaded is false, we use a manual glib timer instead.
45 settings.ExternalMessagePump = false67 settings.ExternalMessagePump = false
46 if cfg.CEFDir != "" {68 if cfg.CEFDir != "" {
47 settings.CEFDir = cfg.CEFDir69 settings.CEFDir = cfg.CEFDir
@@ -63,103 +85,47 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
63 if cfg.LogSeverity != 0 {85 if cfg.LogSeverity != 0 {
64 settings.LogSeverity = cfg.LogSeverity86 settings.LogSeverity = cfg.LogSeverity
65 }87 }
66-88+ if helperPath := findHelperBinary(); helperPath != "" {
67- // NOTE: MaybeExitSubprocess is called in main() before Cobra, so that
68- // CEF subprocess args (--type=renderer) are handled before arg stripping.
69-
70- // Use a dedicated helper binary for CEF subprocesses (renderer, GPU,
71- // utility). The main dumber binary has heavy Go initialization that
72- // corrupts process state before CEF gets control. The helper binary
73- // calls cef_execute_process immediately and exits cleanly.
74- helperPath := findHelperBinary()
75- if helperPath != "" {
76 settings.BrowserSubprocessPath = helperPath89 settings.BrowserSubprocessPath = helperPath
77 logger.Debug().Str("path", helperPath).Msg("cef: using subprocess helper")90 logger.Debug().Str("path", helperPath).Msg("cef: using subprocess helper")
78 } else {91 } else {
79 logger.Warn().Msg("cef: subprocess helper not found, falling back to main binary")92 logger.Warn().Msg("cef: subprocess helper not found, falling back to main binary")
80 }93 }
94+ return settings
95+}
81 96
82- // Chromium flags injected temporarily into os.Args for cef_initialize,97+// initializeCEF calls cef_initialize with or without an App depending on pump mode.
83- // then restored so GTK doesn't choke on unknown flags.98+func initializeCEF(eng *Engine, settings purecef.Settings, multiThreaded bool, logger *zerolog.Logger) error {
84- //
85- // --no-zygote: Disable the zygote process which uses fork(). Go's runtime
86- // does not survive fork cleanly, causing renderer crashes. Without zygote,
87- // CEF launches each subprocess as a fresh exec of the helper binary.
88- //
89- // Thread-safety: safe because this runs during single-threaded startup
90- // before any concurrent goroutines are spawned.
91- savedArgs := os.Args
92- os.Args = appendIfMissing(os.Args, "--no-zygote")
93-
94- // 2. Build the engine early so the App can reference it for the pump callback.
95- eng := &Engine{
96- ctx: ctx,
97- multiThreadedMessageLoop: multiThreaded,
98- manualPumpInterval: manualPumpMs,
99- }
100-
101- logger.Info().
102- Bool("multi_threaded_message_loop", multiThreaded).
103- Int64("manual_pump_interval_ms", manualPumpMs).
104- Int32("windowless_frame_rate", windowlessFrameRate).
105- Bool("trace_handlers", cfg.TraceHandlers).
106- Bool("enable_audio_handler", cfg.EnableAudioHandler).
107- Bool("enable_context_menu_handler", cfg.EnableContextMenuHandler).
108- Msg("cef: configured engine")
109-
110 if multiThreaded {99 if multiThreaded {
111- // In multi-threaded message loop mode CEF owns the browser UI thread, so
112- // Dumber does not rely on BrowserProcessHandler callbacks to drive the
113- // message pump. Initializing without a CefApp avoids the current
114- // purego-cef app bridge issue that causes cef_initialize() to fail early.
115 logger.Debug().Msg("cef: calling Init")100 logger.Debug().Msg("cef: calling Init")
116 if err := purecef.Init(settings); err != nil {101 if err := purecef.Init(settings); err != nil {
117- os.Args = savedArgs102+ return fmt.Errorf("cef.Init: %w", err)
118- return nil, fmt.Errorf("cef.Init: %w", err)
119 }103 }
120- os.Args = savedArgs
121 logger.Debug().Msg("cef: Init returned OK")104 logger.Debug().Msg("cef: Init returned OK")
122 } else {105 } else {
123- // Create the CEF App with a BrowserProcessHandler that drives the
124- // adaptive message pump via OnScheduleMessagePumpWork.
125 app := newDumberApp(eng)106 app := newDumberApp(eng)
126-
127 logger.Debug().Msg("cef: calling InitWithApp")107 logger.Debug().Msg("cef: calling InitWithApp")
128 if err := purecef.InitWithApp(settings, app); err != nil {108 if err := purecef.InitWithApp(settings, app); err != nil {
129- os.Args = savedArgs109+ return fmt.Errorf("cef.InitWithApp: %w", err)
130- return nil, fmt.Errorf("cef.InitWithApp: %w", err)
131 }110 }
132- os.Args = savedArgs
133 logger.Debug().Msg("cef: InitWithApp returned OK")111 logger.Debug().Msg("cef: InitWithApp returned OK")
134 }112 }
113+ return nil
114+}
135 115
136- // 3. Load GL.116+// wireEngine creates GL loader, factory, pool, and scheme handler after CEF init.
117+func wireEngine(
118+ ctx context.Context, eng *Engine, cfg config.CEFEngineConfig,
119+ windowlessFrameRate int32, logger *zerolog.Logger,
120+) (*Engine, error) {
137 gl, err := newGLLoader()121 gl, err := newGLLoader()
138 if err != nil {122 if err != nil {
139 purecef.Shutdown()123 purecef.Shutdown()
140 return nil, fmt.Errorf("GL loader: %w", err)124 return nil, fmt.Errorf("GL loader: %w", err)
141 }125 }
142 126
143- // 4. Detect HiDPI scale from the primary monitor.127+ scale := detectHiDPIScale(logger)
144- scale := int32(1)
145- if display := gdk.DisplayGetDefault(); display == nil {
146- logger.Debug().Msg("cef: no GDK display available, using scale=1")
147- } else if monitors := display.GetMonitors(); monitors == nil {
148- logger.Debug().Msg("cef: no monitors found, using scale=1")
149- } else if obj := monitors.GetObject(0); obj == nil {
150- logger.Debug().Msg("cef: primary monitor not available, using scale=1")
151- } else {
152- mon := &gdk.Monitor{}
153- mon.SetGoPointer(obj.GoPointer())
154- if s := mon.GetScaleFactor(); s > 0 {
155- scale = int32(s)
156- logger.Info().Int32("scale", scale).Msg("cef: detected HiDPI scale from monitor")
157- } else {
158- logger.Debug().Msg("cef: monitor scale factor <= 0, using scale=1")
159- }
160- }
161 128
162- // 5. Create factory + pool and wire them into the engine.
163 factory := newWebViewFactory(eng, gl, webViewFactoryOptions{129 factory := newWebViewFactory(eng, gl, webViewFactoryOptions{
164 scale: scale,130 scale: scale,
165 windowlessFrameRate: windowlessFrameRate,131 windowlessFrameRate: windowlessFrameRate,
@@ -171,10 +137,48 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error)
171 eng.gl = gl137 eng.gl = gl
172 eng.factory = factory138 eng.factory = factory
173 eng.pool = pool139 eng.pool = pool
140+ eng.contentInj = newContentInjector(eng, nil)
141+
142+ messageRouter := NewMessageRouter(ctx)
143+ schemeHandler := newDumbSchemeHandler(ctx, messageRouter)
144+ schemeHandler.setAssets(assets.WebUIAssets)
145+
146+ result := purecef.RegisterSchemeHandlerFactory("dumb", "", purecef.NewSchemeHandlerFactory(schemeHandler))
147+ logger.Info().Int32("result", result).Msg("cef: registered dumb:// scheme handler factory")
148+
149+ eng.messageRouter = messageRouter
150+ eng.schemeHandler = schemeHandler
174 151
175 return eng, nil152 return eng, nil
176 }153 }
177 154
155+// detectHiDPIScale queries the primary GDK monitor for its scale factor.
156+func detectHiDPIScale(logger *zerolog.Logger) int32 {
157+ display := gdk.DisplayGetDefault()
158+ if display == nil {
159+ logger.Debug().Msg("cef: no GDK display available, using scale=1")
160+ return 1
161+ }
162+ monitors := display.GetMonitors()
163+ if monitors == nil {
164+ logger.Debug().Msg("cef: no monitors found, using scale=1")
165+ return 1
166+ }
167+ obj := monitors.GetObject(0)
168+ if obj == nil {
169+ logger.Debug().Msg("cef: primary monitor not available, using scale=1")
170+ return 1
171+ }
172+ mon := &gdk.Monitor{}
173+ mon.SetGoPointer(obj.GoPointer())
174+ if s := mon.GetScaleFactor(); s > 0 {
175+ logger.Info().Int32("scale", int32(s)).Msg("cef: detected HiDPI scale from monitor")
176+ return int32(s)
177+ }
178+ logger.Debug().Msg("cef: monitor scale factor <= 0, using scale=1")
179+ return 1
180+}
181+
178 // appendIfMissing appends flag to args if it's not already present.182 // appendIfMissing appends flag to args if it's not already present.
179 func appendIfMissing(args []string, flag string) []string {183 func appendIfMissing(args []string, flag string) []string {
180 for _, a := range args {184 for _, a := range args {
M internal/infrastructure/cef/factory.go +1 -0
@@ -81,6 +81,7 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
8181 enableContextMenuHandler: f.enableContextMenuHandler,
8282 }
8383 wv.handlers = handlers
84+ wv.findCtrl = newFindController()
8485
8586 input := newInputBridge(f.scale)
8687 input.attachTo(pipeline.glArea)
@@ -81,6 +81,7 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
81 enableContextMenuHandler: f.enableContextMenuHandler,81 enableContextMenuHandler: f.enableContextMenuHandler,
82 }82 }
83 wv.handlers = handlers83 wv.handlers = handlers
84+ wv.findCtrl = newFindController()
84 85
85 input := newInputBridge(f.scale)86 input := newInputBridge(f.scale)
86 input.attachTo(pipeline.glArea)87 input.attachTo(pipeline.glArea)
A internal/infrastructure/cef/find_controller.go +227 -0
@@ -0,0 +1,227 @@
1+package cef
2+
3+import (
4+ "sync"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+// Compile-time interface check.
12+var _ port.FindController = (*cefFindController)(nil)
13+
14+// cefFindController implements port.FindController using CEF's BrowserHost.Find API.
15+type cefFindController struct {
16+ mu sync.RWMutex
17+ host purecef.BrowserHost
18+ searchTxt string
19+ lastID int32 // incremental identifier for the current search
20+ matchCase int32 // cached from most recent Search call
21+
22+ // counting tracks whether the current search is a CountMatches request
23+ // so that handleFindResult dispatches to the right callbacks.
24+ counting bool
25+
26+ // Signal callbacks keyed by unique IDs.
27+ nextSignalID uint
28+ onFound map[uint]func(matchCount uint)
29+ onFailed map[uint]func()
30+ onCounted map[uint]func(matchCount uint)
31+}
32+
33+func newFindController() *cefFindController {
34+ return &cefFindController{
35+ onFound: make(map[uint]func(matchCount uint)),
36+ onFailed: make(map[uint]func()),
37+ onCounted: make(map[uint]func(matchCount uint)),
38+ }
39+}
40+
41+func (fc *cefFindController) setHost(host purecef.BrowserHost) {
42+ fc.mu.Lock()
43+ fc.host = host
44+ fc.mu.Unlock()
45+}
46+
47+// Search starts a new find-in-page search.
48+func (fc *cefFindController) Search(text string, opts port.FindOptions, _ uint) {
49+ fc.mu.Lock()
50+ fc.searchTxt = text
51+ fc.lastID++
52+ fc.counting = false
53+ mc := matchCaseFromOpts(opts)
54+ fc.matchCase = mc
55+ host := fc.host
56+ fc.mu.Unlock()
57+
58+ if host == nil || text == "" {
59+ return
60+ }
61+ host.Find(text, 1, mc, 0) // forward=1, findnext=0 (new search)
62+}
63+
64+// CountMatches starts a search whose results are dispatched to onCounted callbacks.
65+func (fc *cefFindController) CountMatches(text string, opts port.FindOptions, _ uint) {
66+ fc.mu.Lock()
67+ fc.searchTxt = text
68+ fc.lastID++
69+ fc.counting = true
70+ mc := matchCaseFromOpts(opts)
71+ fc.matchCase = mc
72+ host := fc.host
73+ fc.mu.Unlock()
74+
75+ if host == nil || text == "" {
76+ return
77+ }
78+ host.Find(text, 1, mc, 0)
79+}
80+
81+// SearchNext continues the search in the forward direction.
82+func (fc *cefFindController) SearchNext() {
83+ fc.mu.RLock()
84+ host := fc.host
85+ text := fc.searchTxt
86+ mc := fc.matchCase
87+ fc.mu.RUnlock()
88+
89+ if host == nil || text == "" {
90+ return
91+ }
92+ host.Find(text, 1, mc, 1) // forward=1, findnext=1
93+}
94+
95+// SearchPrevious continues the search in the backward direction.
96+func (fc *cefFindController) SearchPrevious() {
97+ fc.mu.RLock()
98+ host := fc.host
99+ text := fc.searchTxt
100+ mc := fc.matchCase
101+ fc.mu.RUnlock()
102+
103+ if host == nil || text == "" {
104+ return
105+ }
106+ host.Find(text, 0, mc, 1) // forward=0, findnext=1
107+}
108+
109+// SearchFinish stops the current search and clears the highlight.
110+func (fc *cefFindController) SearchFinish() {
111+ fc.mu.Lock()
112+ host := fc.host
113+ fc.searchTxt = ""
114+ fc.mu.Unlock()
115+
116+ if host != nil {
117+ host.StopFinding(1)
118+ }
119+}
120+
121+// GetSearchText returns the current search text.
122+func (fc *cefFindController) GetSearchText() string {
123+ fc.mu.RLock()
124+ defer fc.mu.RUnlock()
125+ return fc.searchTxt
126+}
127+
128+// OnFoundText registers a callback fired when matches are found.
129+func (fc *cefFindController) OnFoundText(callback func(matchCount uint)) uint {
130+ if callback == nil {
131+ return 0
132+ }
133+ fc.mu.Lock()
134+ defer fc.mu.Unlock()
135+ fc.nextSignalID++
136+ id := fc.nextSignalID
137+ fc.onFound[id] = callback
138+ return id
139+}
140+
141+// OnFailedToFindText registers a callback fired when no matches are found.
142+func (fc *cefFindController) OnFailedToFindText(callback func()) uint {
143+ if callback == nil {
144+ return 0
145+ }
146+ fc.mu.Lock()
147+ defer fc.mu.Unlock()
148+ fc.nextSignalID++
149+ id := fc.nextSignalID
150+ fc.onFailed[id] = callback
151+ return id
152+}
153+
154+// OnCountedMatches registers a callback fired with the final match count.
155+func (fc *cefFindController) OnCountedMatches(callback func(matchCount uint)) uint {
156+ if callback == nil {
157+ return 0
158+ }
159+ fc.mu.Lock()
160+ defer fc.mu.Unlock()
161+ fc.nextSignalID++
162+ id := fc.nextSignalID
163+ fc.onCounted[id] = callback
164+ return id
165+}
166+
167+// DisconnectSignal removes a previously registered callback.
168+func (fc *cefFindController) DisconnectSignal(id uint) {
169+ if id == 0 {
170+ return
171+ }
172+ fc.mu.Lock()
173+ defer fc.mu.Unlock()
174+ delete(fc.onFound, id)
175+ delete(fc.onFailed, id)
176+ delete(fc.onCounted, id)
177+}
178+
179+// handleFindResult is called by the FindHandler when CEF reports find results.
180+func (fc *cefFindController) handleFindResult(_, count, _, finalUpdate int32) {
181+ fc.mu.RLock()
182+ counting := fc.counting
183+
184+ // Snapshot callback maps so we can invoke them outside the lock.
185+ var foundCBs []func(matchCount uint)
186+ var failedCBs []func()
187+ var countedCBs []func(matchCount uint)
188+
189+ if count > 0 && !counting {
190+ foundCBs = make([]func(matchCount uint), 0, len(fc.onFound))
191+ for _, cb := range fc.onFound {
192+ foundCBs = append(foundCBs, cb)
193+ }
194+ }
195+ if count == 0 && finalUpdate != 0 && !counting {
196+ failedCBs = make([]func(), 0, len(fc.onFailed))
197+ for _, cb := range fc.onFailed {
198+ failedCBs = append(failedCBs, cb)
199+ }
200+ }
201+ if finalUpdate != 0 && counting {
202+ countedCBs = make([]func(matchCount uint), 0, len(fc.onCounted))
203+ for _, cb := range fc.onCounted {
204+ countedCBs = append(countedCBs, cb)
205+ }
206+ }
207+ fc.mu.RUnlock()
208+
209+ for _, cb := range foundCBs {
210+ cb(uint(count))
211+ }
212+ for _, cb := range failedCBs {
213+ cb()
214+ }
215+ for _, cb := range countedCBs {
216+ cb(uint(count))
217+ }
218+}
219+
220+// matchCaseFromOpts converts FindOptions to CEF's matchcase parameter.
221+// CEF: matchcase=1 means case-sensitive, matchcase=0 means case-insensitive.
222+func matchCaseFromOpts(opts port.FindOptions) int32 {
223+ if opts.CaseInsensitive {
224+ return 0
225+ }
226+ return 1
227+}
@@ -0,0 +1,227 @@
1+package cef
2+
3+import (
4+ "sync"
5+
6+ purecef "github.com/bnema/purego-cef/cef"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+// Compile-time interface check.
12+var _ port.FindController = (*cefFindController)(nil)
13+
14+// cefFindController implements port.FindController using CEF's BrowserHost.Find API.
15+type cefFindController struct {
16+ mu sync.RWMutex
17+ host purecef.BrowserHost
18+ searchTxt string
19+ lastID int32 // incremental identifier for the current search
20+ matchCase int32 // cached from most recent Search call
21+
22+ // counting tracks whether the current search is a CountMatches request
23+ // so that handleFindResult dispatches to the right callbacks.
24+ counting bool
25+
26+ // Signal callbacks keyed by unique IDs.
27+ nextSignalID uint
28+ onFound map[uint]func(matchCount uint)
29+ onFailed map[uint]func()
30+ onCounted map[uint]func(matchCount uint)
31+}
32+
33+func newFindController() *cefFindController {
34+ return &cefFindController{
35+ onFound: make(map[uint]func(matchCount uint)),
36+ onFailed: make(map[uint]func()),
37+ onCounted: make(map[uint]func(matchCount uint)),
38+ }
39+}
40+
41+func (fc *cefFindController) setHost(host purecef.BrowserHost) {
42+ fc.mu.Lock()
43+ fc.host = host
44+ fc.mu.Unlock()
45+}
46+
47+// Search starts a new find-in-page search.
48+func (fc *cefFindController) Search(text string, opts port.FindOptions, _ uint) {
49+ fc.mu.Lock()
50+ fc.searchTxt = text
51+ fc.lastID++
52+ fc.counting = false
53+ mc := matchCaseFromOpts(opts)
54+ fc.matchCase = mc
55+ host := fc.host
56+ fc.mu.Unlock()
57+
58+ if host == nil || text == "" {
59+ return
60+ }
61+ host.Find(text, 1, mc, 0) // forward=1, findnext=0 (new search)
62+}
63+
64+// CountMatches starts a search whose results are dispatched to onCounted callbacks.
65+func (fc *cefFindController) CountMatches(text string, opts port.FindOptions, _ uint) {
66+ fc.mu.Lock()
67+ fc.searchTxt = text
68+ fc.lastID++
69+ fc.counting = true
70+ mc := matchCaseFromOpts(opts)
71+ fc.matchCase = mc
72+ host := fc.host
73+ fc.mu.Unlock()
74+
75+ if host == nil || text == "" {
76+ return
77+ }
78+ host.Find(text, 1, mc, 0)
79+}
80+
81+// SearchNext continues the search in the forward direction.
82+func (fc *cefFindController) SearchNext() {
83+ fc.mu.RLock()
84+ host := fc.host
85+ text := fc.searchTxt
86+ mc := fc.matchCase
87+ fc.mu.RUnlock()
88+
89+ if host == nil || text == "" {
90+ return
91+ }
92+ host.Find(text, 1, mc, 1) // forward=1, findnext=1
93+}
94+
95+// SearchPrevious continues the search in the backward direction.
96+func (fc *cefFindController) SearchPrevious() {
97+ fc.mu.RLock()
98+ host := fc.host
99+ text := fc.searchTxt
100+ mc := fc.matchCase
101+ fc.mu.RUnlock()
102+
103+ if host == nil || text == "" {
104+ return
105+ }
106+ host.Find(text, 0, mc, 1) // forward=0, findnext=1
107+}
108+
109+// SearchFinish stops the current search and clears the highlight.
110+func (fc *cefFindController) SearchFinish() {
111+ fc.mu.Lock()
112+ host := fc.host
113+ fc.searchTxt = ""
114+ fc.mu.Unlock()
115+
116+ if host != nil {
117+ host.StopFinding(1)
118+ }
119+}
120+
121+// GetSearchText returns the current search text.
122+func (fc *cefFindController) GetSearchText() string {
123+ fc.mu.RLock()
124+ defer fc.mu.RUnlock()
125+ return fc.searchTxt
126+}
127+
128+// OnFoundText registers a callback fired when matches are found.
129+func (fc *cefFindController) OnFoundText(callback func(matchCount uint)) uint {
130+ if callback == nil {
131+ return 0
132+ }
133+ fc.mu.Lock()
134+ defer fc.mu.Unlock()
135+ fc.nextSignalID++
136+ id := fc.nextSignalID
137+ fc.onFound[id] = callback
138+ return id
139+}
140+
141+// OnFailedToFindText registers a callback fired when no matches are found.
142+func (fc *cefFindController) OnFailedToFindText(callback func()) uint {
143+ if callback == nil {
144+ return 0
145+ }
146+ fc.mu.Lock()
147+ defer fc.mu.Unlock()
148+ fc.nextSignalID++
149+ id := fc.nextSignalID
150+ fc.onFailed[id] = callback
151+ return id
152+}
153+
154+// OnCountedMatches registers a callback fired with the final match count.
155+func (fc *cefFindController) OnCountedMatches(callback func(matchCount uint)) uint {
156+ if callback == nil {
157+ return 0
158+ }
159+ fc.mu.Lock()
160+ defer fc.mu.Unlock()
161+ fc.nextSignalID++
162+ id := fc.nextSignalID
163+ fc.onCounted[id] = callback
164+ return id
165+}
166+
167+// DisconnectSignal removes a previously registered callback.
168+func (fc *cefFindController) DisconnectSignal(id uint) {
169+ if id == 0 {
170+ return
171+ }
172+ fc.mu.Lock()
173+ defer fc.mu.Unlock()
174+ delete(fc.onFound, id)
175+ delete(fc.onFailed, id)
176+ delete(fc.onCounted, id)
177+}
178+
179+// handleFindResult is called by the FindHandler when CEF reports find results.
180+func (fc *cefFindController) handleFindResult(_, count, _, finalUpdate int32) {
181+ fc.mu.RLock()
182+ counting := fc.counting
183+
184+ // Snapshot callback maps so we can invoke them outside the lock.
185+ var foundCBs []func(matchCount uint)
186+ var failedCBs []func()
187+ var countedCBs []func(matchCount uint)
188+
189+ if count > 0 && !counting {
190+ foundCBs = make([]func(matchCount uint), 0, len(fc.onFound))
191+ for _, cb := range fc.onFound {
192+ foundCBs = append(foundCBs, cb)
193+ }
194+ }
195+ if count == 0 && finalUpdate != 0 && !counting {
196+ failedCBs = make([]func(), 0, len(fc.onFailed))
197+ for _, cb := range fc.onFailed {
198+ failedCBs = append(failedCBs, cb)
199+ }
200+ }
201+ if finalUpdate != 0 && counting {
202+ countedCBs = make([]func(matchCount uint), 0, len(fc.onCounted))
203+ for _, cb := range fc.onCounted {
204+ countedCBs = append(countedCBs, cb)
205+ }
206+ }
207+ fc.mu.RUnlock()
208+
209+ for _, cb := range foundCBs {
210+ cb(uint(count))
211+ }
212+ for _, cb := range failedCBs {
213+ cb()
214+ }
215+ for _, cb := range countedCBs {
216+ cb(uint(count))
217+ }
218+}
219+
220+// matchCaseFromOpts converts FindOptions to CEF's matchcase parameter.
221+// CEF: matchcase=1 means case-sensitive, matchcase=0 means case-insensitive.
222+func matchCaseFromOpts(opts port.FindOptions) int32 {
223+ if opts.CaseInsensitive {
224+ return 0
225+ }
226+ return 1
227+}
M internal/infrastructure/cef/input_bridge.go +6 -4
@@ -72,13 +72,15 @@ func (ib *inputBridge) setHost(host purecef.BrowserHost) {
7272 func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {
7373 // Motion (mouse move + enter/leave)
7474 motion := gtk.NewEventControllerMotion()
75- motionCb := func(_ gtk.EventControllerMotion, x, y float64) {
76- ib.onMouseMove(x, y, 0, false)
75+ motionCb := func(g gtk.EventControllerMotion, x, y float64) {
76+ mods := uint(g.GetCurrentEventState())
77+ ib.onMouseMove(x, y, mods, false)
7778 }
7879 motion.ConnectMotion(&motionCb)
7980
80- leaveCb := func(_ gtk.EventControllerMotion) {
81- ib.onMouseMove(0, 0, 0, true)
81+ leaveCb := func(g gtk.EventControllerMotion) {
82+ mods := uint(g.GetCurrentEventState())
83+ ib.onMouseMove(0, 0, mods, true)
8284 }
8385 motion.ConnectLeave(&leaveCb)
8486
@@ -72,13 +72,15 @@ func (ib *inputBridge) setHost(host purecef.BrowserHost) {
72 func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {72 func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {
73 // Motion (mouse move + enter/leave)73 // Motion (mouse move + enter/leave)
74 motion := gtk.NewEventControllerMotion()74 motion := gtk.NewEventControllerMotion()
75- motionCb := func(_ gtk.EventControllerMotion, x, y float64) {75+ motionCb := func(g gtk.EventControllerMotion, x, y float64) {
76- ib.onMouseMove(x, y, 0, false)76+ mods := uint(g.GetCurrentEventState())
77+ ib.onMouseMove(x, y, mods, false)
77 }78 }
78 motion.ConnectMotion(&motionCb)79 motion.ConnectMotion(&motionCb)
79 80
80- leaveCb := func(_ gtk.EventControllerMotion) {81+ leaveCb := func(g gtk.EventControllerMotion) {
81- ib.onMouseMove(0, 0, 0, true)82+ mods := uint(g.GetCurrentEventState())
83+ ib.onMouseMove(0, 0, mods, true)
82 }84 }
83 motion.ConnectLeave(&leaveCb)85 motion.ConnectLeave(&leaveCb)
84 86
A internal/infrastructure/cef/messaging.go +187 -0
@@ -0,0 +1,187 @@
1+package cef
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "sync"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// Message represents a JS -> Go message envelope sent via fetch to /api/message.
14+type Message struct {
15+ Type string `json:"type"`
16+ Payload json.RawMessage `json:"payload"`
17+ WebViewID uint64 `json:"webview_id,omitempty"`
18+ WebViewIDAlt uint64 `json:"webviewId,omitempty"`
19+}
20+
21+// MessageHandler handles a decoded message payload.
22+type MessageHandler interface {
23+ Handle(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error)
24+}
25+
26+// MessageHandlerFunc adapts a function to the MessageHandler interface.
27+type MessageHandlerFunc func(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error)
28+
29+// Handle calls f(ctx, webviewID, payload).
30+func (f MessageHandlerFunc) Handle(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error) {
31+ return f(ctx, webviewID, payload)
32+}
33+
34+type handlerEntry struct {
35+ handler MessageHandler
36+ callback string
37+ errorCallback string
38+ world string
39+}
40+
41+// MessageRouter dispatches fetch-based message events to registered handlers.
42+type MessageRouter struct {
43+ handlers map[string]handlerEntry
44+ baseCtx context.Context
45+ mu sync.RWMutex
46+}
47+
48+// NewMessageRouter creates a new message router.
49+func NewMessageRouter(ctx context.Context) *MessageRouter {
50+ if ctx == nil {
51+ ctx = context.Background()
52+ }
53+ return &MessageRouter{
54+ handlers: make(map[string]handlerEntry),
55+ baseCtx: ctx,
56+ }
57+}
58+
59+// SetBaseContext updates the base context used for handler execution.
60+func (r *MessageRouter) SetBaseContext(ctx context.Context) {
61+ if r == nil {
62+ return
63+ }
64+ if ctx == nil {
65+ ctx = context.Background()
66+ }
67+ r.baseCtx = ctx
68+}
69+
70+// RegisterHandler registers a handler for a message type.
71+func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {
72+ if msgType == "" {
73+ return errors.New("message type cannot be empty")
74+ }
75+ if handler == nil {
76+ return errors.New("message handler cannot be nil")
77+ }
78+ r.mu.Lock()
79+ defer r.mu.Unlock()
80+ r.handlers[msgType] = handlerEntry{handler: handler}
81+ return nil
82+}
83+
84+// RegisterHandlerWithCallbacks registers a handler with response callback names.
85+// callback is invoked on success, errorCallback (optional) on failure.
86+// worldName allows targeting a specific script world (unused in CEF fetch bridge).
87+func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCallback, worldName string, handler MessageHandler) error {
88+ if msgType == "" {
89+ return errors.New("message type cannot be empty")
90+ }
91+ if handler == nil {
92+ return errors.New("message handler cannot be nil")
93+ }
94+ if callback == "" {
95+ return errors.New("callback cannot be empty when registering with callbacks")
96+ }
97+ r.mu.Lock()
98+ defer r.mu.Unlock()
99+ r.handlers[msgType] = handlerEntry{
100+ handler: handler,
101+ callback: callback,
102+ errorCallback: errorCallback,
103+ world: worldName,
104+ }
105+ return nil
106+}
107+
108+// HandleMessage decodes a raw JSON message body and routes it to the correct handler.
109+// Returns the JSON-encoded response. Called by the scheme handler for /api/message POST.
110+func (r *MessageRouter) HandleMessage(ctx context.Context, webviewID uint64, body []byte) ([]byte, error) {
111+ log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
112+
113+ var msg Message
114+ if err := json.Unmarshal(body, &msg); err != nil {
115+ return nil, fmt.Errorf("unmarshal message: %w", err)
116+ }
117+ if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
118+ msg.WebViewID = msg.WebViewIDAlt
119+ }
120+ if msg.WebViewID == 0 {
121+ msg.WebViewID = webviewID
122+ }
123+ if msg.Type == "" {
124+ return nil, errors.New("message missing type")
125+ }
126+
127+ r.mu.RLock()
128+ entry, ok := r.handlers[msg.Type]
129+ r.mu.RUnlock()
130+
131+ if !ok || entry.handler == nil {
132+ log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")
133+ return json.Marshal(map[string]string{"error": "unknown message type: " + msg.Type})
134+ }
135+
136+ log.Info().
137+ Str("type", msg.Type).
138+ Uint64("webview_id", msg.WebViewID).
139+ Int("payload_len", len(msg.Payload)).
140+ Msg("dispatching message to handler")
141+
142+ if ctx == nil {
143+ ctx = r.baseCtx
144+ }
145+ resp, err := entry.handler.Handle(ctx, msg.WebViewID, msg.Payload)
146+ if err != nil {
147+ log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
148+ result := map[string]any{"error": err.Error()}
149+ if entry.errorCallback != "" {
150+ result["_callback"] = entry.errorCallback
151+ }
152+ return json.Marshal(result)
153+ }
154+
155+ result := map[string]any{"data": resp}
156+ if entry.callback != "" {
157+ result["_callback"] = entry.callback
158+ }
159+ return json.Marshal(result)
160+}
161+
162+// MessageBridgeJS is the JavaScript shim injected into dumb:// pages to provide
163+// the window.dumber.postMessage() bridge via fetch.
164+//
165+// The message body is sent via the X-Dumber-Body header (base64-encoded) rather
166+// than the fetch body, because CEF's PostData element API (GetElements/GetBytes)
167+// requires unexported wrappers in purego-cef. Using a header avoids that limitation
168+// while keeping the bridge simple and reliable.
169+const MessageBridgeJS = `
170+window.dumber = {
171+ postMessage: async function(msg) {
172+ const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg))));
173+ const r = await fetch('/api/message', {
174+ method: 'POST',
175+ headers: {
176+ 'Content-Type': 'application/json',
177+ 'X-Dumber-Body': encoded
178+ }
179+ });
180+ const resp = await r.json();
181+ if (resp && resp._callback && window[resp._callback]) {
182+ window[resp._callback](resp.data);
183+ }
184+ return resp;
185+ }
186+};
187+`
@@ -0,0 +1,187 @@
1+package cef
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "sync"
9+
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+// Message represents a JS -> Go message envelope sent via fetch to /api/message.
14+type Message struct {
15+ Type string `json:"type"`
16+ Payload json.RawMessage `json:"payload"`
17+ WebViewID uint64 `json:"webview_id,omitempty"`
18+ WebViewIDAlt uint64 `json:"webviewId,omitempty"`
19+}
20+
21+// MessageHandler handles a decoded message payload.
22+type MessageHandler interface {
23+ Handle(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error)
24+}
25+
26+// MessageHandlerFunc adapts a function to the MessageHandler interface.
27+type MessageHandlerFunc func(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error)
28+
29+// Handle calls f(ctx, webviewID, payload).
30+func (f MessageHandlerFunc) Handle(ctx context.Context, webviewID uint64, payload json.RawMessage) (any, error) {
31+ return f(ctx, webviewID, payload)
32+}
33+
34+type handlerEntry struct {
35+ handler MessageHandler
36+ callback string
37+ errorCallback string
38+ world string
39+}
40+
41+// MessageRouter dispatches fetch-based message events to registered handlers.
42+type MessageRouter struct {
43+ handlers map[string]handlerEntry
44+ baseCtx context.Context
45+ mu sync.RWMutex
46+}
47+
48+// NewMessageRouter creates a new message router.
49+func NewMessageRouter(ctx context.Context) *MessageRouter {
50+ if ctx == nil {
51+ ctx = context.Background()
52+ }
53+ return &MessageRouter{
54+ handlers: make(map[string]handlerEntry),
55+ baseCtx: ctx,
56+ }
57+}
58+
59+// SetBaseContext updates the base context used for handler execution.
60+func (r *MessageRouter) SetBaseContext(ctx context.Context) {
61+ if r == nil {
62+ return
63+ }
64+ if ctx == nil {
65+ ctx = context.Background()
66+ }
67+ r.baseCtx = ctx
68+}
69+
70+// RegisterHandler registers a handler for a message type.
71+func (r *MessageRouter) RegisterHandler(msgType string, handler MessageHandler) error {
72+ if msgType == "" {
73+ return errors.New("message type cannot be empty")
74+ }
75+ if handler == nil {
76+ return errors.New("message handler cannot be nil")
77+ }
78+ r.mu.Lock()
79+ defer r.mu.Unlock()
80+ r.handlers[msgType] = handlerEntry{handler: handler}
81+ return nil
82+}
83+
84+// RegisterHandlerWithCallbacks registers a handler with response callback names.
85+// callback is invoked on success, errorCallback (optional) on failure.
86+// worldName allows targeting a specific script world (unused in CEF fetch bridge).
87+func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCallback, worldName string, handler MessageHandler) error {
88+ if msgType == "" {
89+ return errors.New("message type cannot be empty")
90+ }
91+ if handler == nil {
92+ return errors.New("message handler cannot be nil")
93+ }
94+ if callback == "" {
95+ return errors.New("callback cannot be empty when registering with callbacks")
96+ }
97+ r.mu.Lock()
98+ defer r.mu.Unlock()
99+ r.handlers[msgType] = handlerEntry{
100+ handler: handler,
101+ callback: callback,
102+ errorCallback: errorCallback,
103+ world: worldName,
104+ }
105+ return nil
106+}
107+
108+// HandleMessage decodes a raw JSON message body and routes it to the correct handler.
109+// Returns the JSON-encoded response. Called by the scheme handler for /api/message POST.
110+func (r *MessageRouter) HandleMessage(ctx context.Context, webviewID uint64, body []byte) ([]byte, error) {
111+ log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger()
112+
113+ var msg Message
114+ if err := json.Unmarshal(body, &msg); err != nil {
115+ return nil, fmt.Errorf("unmarshal message: %w", err)
116+ }
117+ if msg.WebViewID == 0 && msg.WebViewIDAlt != 0 {
118+ msg.WebViewID = msg.WebViewIDAlt
119+ }
120+ if msg.WebViewID == 0 {
121+ msg.WebViewID = webviewID
122+ }
123+ if msg.Type == "" {
124+ return nil, errors.New("message missing type")
125+ }
126+
127+ r.mu.RLock()
128+ entry, ok := r.handlers[msg.Type]
129+ r.mu.RUnlock()
130+
131+ if !ok || entry.handler == nil {
132+ log.Warn().Str("type", msg.Type).Msg("no handler registered for message type")
133+ return json.Marshal(map[string]string{"error": "unknown message type: " + msg.Type})
134+ }
135+
136+ log.Info().
137+ Str("type", msg.Type).
138+ Uint64("webview_id", msg.WebViewID).
139+ Int("payload_len", len(msg.Payload)).
140+ Msg("dispatching message to handler")
141+
142+ if ctx == nil {
143+ ctx = r.baseCtx
144+ }
145+ resp, err := entry.handler.Handle(ctx, msg.WebViewID, msg.Payload)
146+ if err != nil {
147+ log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
148+ result := map[string]any{"error": err.Error()}
149+ if entry.errorCallback != "" {
150+ result["_callback"] = entry.errorCallback
151+ }
152+ return json.Marshal(result)
153+ }
154+
155+ result := map[string]any{"data": resp}
156+ if entry.callback != "" {
157+ result["_callback"] = entry.callback
158+ }
159+ return json.Marshal(result)
160+}
161+
162+// MessageBridgeJS is the JavaScript shim injected into dumb:// pages to provide
163+// the window.dumber.postMessage() bridge via fetch.
164+//
165+// The message body is sent via the X-Dumber-Body header (base64-encoded) rather
166+// than the fetch body, because CEF's PostData element API (GetElements/GetBytes)
167+// requires unexported wrappers in purego-cef. Using a header avoids that limitation
168+// while keeping the bridge simple and reliable.
169+const MessageBridgeJS = `
170+window.dumber = {
171+ postMessage: async function(msg) {
172+ const encoded = btoa(unescape(encodeURIComponent(JSON.stringify(msg))));
173+ const r = await fetch('/api/message', {
174+ method: 'POST',
175+ headers: {
176+ 'Content-Type': 'application/json',
177+ 'X-Dumber-Body': encoded
178+ }
179+ });
180+ const resp = await r.json();
181+ if (resp && resp._callback && window[resp._callback]) {
182+ window[resp._callback](resp.data);
183+ }
184+ return resp;
185+ }
186+};
187+`
A internal/infrastructure/cef/scheme_handler.go +350 -0
@@ -0,0 +1,350 @@
1+package cef
2+
3+import (
4+ "context"
5+ "embed"
6+ "encoding/base64"
7+ "encoding/json"
8+ "fmt"
9+ "io/fs"
10+ "mime"
11+ "net/http"
12+ "net/url"
13+ "path/filepath"
14+ "strings"
15+ "sync"
16+ "unsafe"
17+
18+ purecef "github.com/bnema/purego-cef/cef"
19+
20+ "github.com/bnema/dumber/internal/infrastructure/config"
21+ "github.com/bnema/dumber/internal/logging"
22+ "github.com/rs/zerolog"
23+)
24+
25+// Scheme path constants matching WebKit's naming.
26+const (
27+ homePath = "home"
28+ configPath = "config"
29+ webrtcPath = "webrtc"
30+ errorPath = "error"
31+ indexHTML = "index.html"
32+)
33+
34+// dumbSchemeHandler implements purecef.SchemeHandlerFactory for the dumb:// scheme.
35+type dumbSchemeHandler struct {
36+ ctx context.Context
37+ messageRouter *MessageRouter
38+ assets embed.FS
39+ assetDir string
40+ logger zerolog.Logger
41+ mu sync.RWMutex
42+}
43+
44+// newDumbSchemeHandler creates a handler for the dumb:// scheme.
45+func newDumbSchemeHandler(ctx context.Context, router *MessageRouter) *dumbSchemeHandler {
46+ log := logging.FromContext(ctx)
47+ return &dumbSchemeHandler{
48+ ctx: ctx,
49+ messageRouter: router,
50+ assetDir: "webui",
51+ logger: log.With().Str("component", "scheme-handler").Logger(),
52+ }
53+}
54+
55+// setAssets sets the embedded filesystem containing webui assets.
56+func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
57+ h.mu.Lock()
58+ defer h.mu.Unlock()
59+ h.assets = assets
60+ h.logger.Debug().Msg("assets filesystem configured")
61+}
62+
63+// Create implements purecef.SchemeHandlerFactory. Called on CEF IO thread for each
64+// request to the dumb:// scheme. Returns a ResourceHandler to serve the response.
65+func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler {
66+ reqURL := request.GetURL()
67+ method := request.GetMethod()
68+
69+ h.logger.Debug().
70+ Str("url", reqURL).
71+ Str("method", method).
72+ Msg("scheme handler create")
73+
74+ u, err := url.Parse(reqURL)
75+ if err != nil {
76+ h.logger.Error().Err(err).Str("url", reqURL).Msg("invalid URL")
77+ return h.newErrorResourceHandler(http.StatusBadRequest, "Invalid URL")
78+ }
79+
80+ // Route API requests.
81+ path := u.Path
82+ if path == "" && u.Opaque != "" {
83+ // Handle opaque URLs like dumb:home — no path component.
84+ path = ""
85+ }
86+
87+ if strings.HasPrefix(path, "/api/") {
88+ return h.handleAPI(method, path, request)
89+ }
90+
91+ // Serve static assets.
92+ return h.handleAsset(u)
93+}
94+
95+// handleAPI routes API requests to the message router or built-in handlers.
96+func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Request) purecef.ResourceHandler {
97+ switch {
98+ case path == "/api/message" && strings.EqualFold(method, "POST"):
99+ body := readBodyFromHeader(request)
100+ if body == nil {
101+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
102+ }
103+ resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body)
104+ if err != nil {
105+ return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
106+ }
107+ return h.newRawResourceHandler(http.StatusOK, "application/json", resp)
108+
109+ case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")):
110+ return h.handleConfigAPI(config.Get())
111+
112+ case path == "/api/config/default" && (method == "" || strings.EqualFold(method, "GET")):
113+ return h.handleConfigAPI(config.DefaultConfig())
114+
115+ default:
116+ return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
117+ }
118+}
119+
120+// handleConfigAPI returns a JSON response with the given config.
121+func (h *dumbSchemeHandler) handleConfigAPI(cfg *config.Config) purecef.ResourceHandler {
122+ data, err := json.Marshal(cfg)
123+ if err != nil {
124+ return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
125+ }
126+ return h.newRawResourceHandler(http.StatusOK, "application/json", data)
127+}
128+
129+// handleAsset serves static files from the embedded filesystem.
130+func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
131+ h.mu.RLock()
132+ hasAssets := h.assets != (embed.FS{})
133+ assetDir := h.assetDir
134+ h.mu.RUnlock()
135+
136+ if !hasAssets {
137+ return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured")
138+ }
139+
140+ relPath, ok := resolveAssetPath(u)
141+ if !ok {
142+ return h.newErrorResourceHandler(http.StatusNotFound, "Page not found")
143+ }
144+
145+ fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
146+ data, err := fs.ReadFile(h.assets, fullPath)
147+ if err != nil {
148+ h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
149+ return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
150+ }
151+
152+ contentType := getMimeType(relPath)
153+ h.logger.Debug().
154+ Str("path", fullPath).
155+ Str("content_type", contentType).
156+ Int("size", len(data)).
157+ Msg("serving asset")
158+
159+ return h.newRawResourceHandler(http.StatusOK, contentType, data)
160+}
161+
162+// resolveAssetPath maps a dumb:// URL to a relative asset path.
163+func resolveAssetPath(u *url.URL) (string, bool) {
164+ if u == nil {
165+ return "", false
166+ }
167+
168+ rootByHost := map[string]string{
169+ homePath: indexHTML,
170+ configPath: "config.html",
171+ webrtcPath: "webrtc.html",
172+ errorPath: "error.html",
173+ }
174+
175+ if root, ok := rootByHost[u.Host]; ok {
176+ path := strings.TrimPrefix(u.Path, "/")
177+ if path == "" {
178+ return root, true
179+ }
180+ // Don't serve API paths as assets.
181+ if strings.HasPrefix(path, "api/") {
182+ return "", false
183+ }
184+ return path, true
185+ }
186+
187+ // Handle opaque URLs like dumb:home.
188+ switch u.Opaque {
189+ case homePath:
190+ return indexHTML, true
191+ case configPath:
192+ return "config.html", true
193+ case webrtcPath:
194+ return "webrtc.html", true
195+ case errorPath:
196+ return "error.html", true
197+ default:
198+ return "", false
199+ }
200+}
201+
202+// getMimeType determines the MIME type for a given file path.
203+func getMimeType(filename string) string {
204+ ext := strings.ToLower(filepath.Ext(filename))
205+
206+ mt := mime.TypeByExtension(ext)
207+ if mt != "" {
208+ return mt
209+ }
210+
211+ switch ext {
212+ case ".js", ".mjs":
213+ return "application/javascript"
214+ case ".css":
215+ return "text/css"
216+ case ".svg":
217+ return "image/svg+xml"
218+ case ".ico":
219+ return "image/x-icon"
220+ case ".woff":
221+ return "font/woff"
222+ case ".woff2":
223+ return "font/woff2"
224+ case ".ttf":
225+ return "font/ttf"
226+ case ".otf":
227+ return "font/otf"
228+ case ".png":
229+ return "image/png"
230+ case ".jpg", ".jpeg":
231+ return "image/jpeg"
232+ case ".gif":
233+ return "image/gif"
234+ case ".webp":
235+ return "image/webp"
236+ case ".json":
237+ return "application/json"
238+ case ".xml":
239+ return "application/xml"
240+ case ".html", ".htm":
241+ return "text/html; charset=utf-8"
242+ default:
243+ return "text/plain"
244+ }
245+}
246+
247+// ---------------------------------------------------------------------------
248+// ResourceHandler implementation
249+// ---------------------------------------------------------------------------
250+
251+// staticResourceHandler serves a fixed byte buffer as a CEF ResourceHandler.
252+type staticResourceHandler struct {
253+ data []byte
254+ contentType string
255+ statusCode int
256+ offset int
257+}
258+
259+func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
260+ return purecef.NewResourceHandler(&staticResourceHandler{
261+ data: data,
262+ contentType: contentType,
263+ statusCode: status,
264+ })
265+}
266+
267+func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
268+ body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, msg)
269+ return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))
270+}
271+
272+func (h *dumbSchemeHandler) newJSONResourceHandler(status int, v any) purecef.ResourceHandler {
273+ data, _ := json.Marshal(v)
274+ return h.newRawResourceHandler(status, "application/json", data)
275+}
276+
277+// Open handles the request immediately (synchronous).
278+func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest unsafe.Pointer, _ purecef.Callback) int32 {
279+ // Set handleRequest = true (1) to indicate we handle it immediately.
280+ *(*int32)(handleRequest) = 1
281+ return 1
282+}
283+
284+// ProcessRequest is deprecated; Open is used instead.
285+func (rh *staticResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Callback) int32 {
286+ return 0
287+}
288+
289+// GetResponseHeaders sets status code, MIME type, and content length.
290+func (rh *staticResourceHandler) GetResponseHeaders(response purecef.Response, responseLength unsafe.Pointer, _ uintptr) {
291+ response.SetStatus(int32(rh.statusCode))
292+ response.SetMimeType(rh.contentType)
293+ *(*int64)(responseLength) = int64(len(rh.data))
294+}
295+
296+// Skip is not used for static content.
297+func (rh *staticResourceHandler) Skip(_ int64, _ unsafe.Pointer, _ purecef.ResourceSkipCallback) int32 {
298+ return 0
299+}
300+
301+// Read copies data into the output buffer.
302+func (rh *staticResourceHandler) Read(
303+ dataOut unsafe.Pointer, bytesToRead int32,
304+ bytesRead unsafe.Pointer, _ purecef.ResourceReadCallback,
305+) int32 {
306+ if rh.offset >= len(rh.data) {
307+ return 0
308+ }
309+
310+ remaining := len(rh.data) - rh.offset
311+ toRead := int(bytesToRead)
312+ if toRead > remaining {
313+ toRead = remaining
314+ }
315+
316+ // Copy data to the output buffer.
317+ dst := unsafe.Slice((*byte)(dataOut), toRead)
318+ copy(dst, rh.data[rh.offset:rh.offset+toRead])
319+ rh.offset += toRead
320+
321+ *(*int32)(bytesRead) = int32(toRead)
322+ return 1
323+}
324+
325+// ReadResponse is deprecated; Read is used instead.
326+func (rh *staticResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ unsafe.Pointer, _ purecef.Callback) int32 {
327+ return 0
328+}
329+
330+// Cancel is a no-op for static content.
331+func (rh *staticResourceHandler) Cancel() {}
332+
333+// ---------------------------------------------------------------------------
334+// Request body reader
335+// ---------------------------------------------------------------------------
336+
337+// readBodyFromHeader extracts the message body from the X-Dumber-Body header.
338+// The JS bridge base64-encodes the JSON body into this header to avoid the
339+// purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported).
340+func readBodyFromHeader(request purecef.Request) []byte {
341+ encoded := request.GetHeaderByName("X-Dumber-Body")
342+ if encoded == "" {
343+ return nil
344+ }
345+ body, err := base64.StdEncoding.DecodeString(encoded)
346+ if err != nil {
347+ return nil
348+ }
349+ return body
350+}
@@ -0,0 +1,350 @@
1+package cef
2+
3+import (
4+ "context"
5+ "embed"
6+ "encoding/base64"
7+ "encoding/json"
8+ "fmt"
9+ "io/fs"
10+ "mime"
11+ "net/http"
12+ "net/url"
13+ "path/filepath"
14+ "strings"
15+ "sync"
16+ "unsafe"
17+
18+ purecef "github.com/bnema/purego-cef/cef"
19+
20+ "github.com/bnema/dumber/internal/infrastructure/config"
21+ "github.com/bnema/dumber/internal/logging"
22+ "github.com/rs/zerolog"
23+)
24+
25+// Scheme path constants matching WebKit's naming.
26+const (
27+ homePath = "home"
28+ configPath = "config"
29+ webrtcPath = "webrtc"
30+ errorPath = "error"
31+ indexHTML = "index.html"
32+)
33+
34+// dumbSchemeHandler implements purecef.SchemeHandlerFactory for the dumb:// scheme.
35+type dumbSchemeHandler struct {
36+ ctx context.Context
37+ messageRouter *MessageRouter
38+ assets embed.FS
39+ assetDir string
40+ logger zerolog.Logger
41+ mu sync.RWMutex
42+}
43+
44+// newDumbSchemeHandler creates a handler for the dumb:// scheme.
45+func newDumbSchemeHandler(ctx context.Context, router *MessageRouter) *dumbSchemeHandler {
46+ log := logging.FromContext(ctx)
47+ return &dumbSchemeHandler{
48+ ctx: ctx,
49+ messageRouter: router,
50+ assetDir: "webui",
51+ logger: log.With().Str("component", "scheme-handler").Logger(),
52+ }
53+}
54+
55+// setAssets sets the embedded filesystem containing webui assets.
56+func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
57+ h.mu.Lock()
58+ defer h.mu.Unlock()
59+ h.assets = assets
60+ h.logger.Debug().Msg("assets filesystem configured")
61+}
62+
63+// Create implements purecef.SchemeHandlerFactory. Called on CEF IO thread for each
64+// request to the dumb:// scheme. Returns a ResourceHandler to serve the response.
65+func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler {
66+ reqURL := request.GetURL()
67+ method := request.GetMethod()
68+
69+ h.logger.Debug().
70+ Str("url", reqURL).
71+ Str("method", method).
72+ Msg("scheme handler create")
73+
74+ u, err := url.Parse(reqURL)
75+ if err != nil {
76+ h.logger.Error().Err(err).Str("url", reqURL).Msg("invalid URL")
77+ return h.newErrorResourceHandler(http.StatusBadRequest, "Invalid URL")
78+ }
79+
80+ // Route API requests.
81+ path := u.Path
82+ if path == "" && u.Opaque != "" {
83+ // Handle opaque URLs like dumb:home — no path component.
84+ path = ""
85+ }
86+
87+ if strings.HasPrefix(path, "/api/") {
88+ return h.handleAPI(method, path, request)
89+ }
90+
91+ // Serve static assets.
92+ return h.handleAsset(u)
93+}
94+
95+// handleAPI routes API requests to the message router or built-in handlers.
96+func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Request) purecef.ResourceHandler {
97+ switch {
98+ case path == "/api/message" && strings.EqualFold(method, "POST"):
99+ body := readBodyFromHeader(request)
100+ if body == nil {
101+ return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
102+ }
103+ resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body)
104+ if err != nil {
105+ return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
106+ }
107+ return h.newRawResourceHandler(http.StatusOK, "application/json", resp)
108+
109+ case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")):
110+ return h.handleConfigAPI(config.Get())
111+
112+ case path == "/api/config/default" && (method == "" || strings.EqualFold(method, "GET")):
113+ return h.handleConfigAPI(config.DefaultConfig())
114+
115+ default:
116+ return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
117+ }
118+}
119+
120+// handleConfigAPI returns a JSON response with the given config.
121+func (h *dumbSchemeHandler) handleConfigAPI(cfg *config.Config) purecef.ResourceHandler {
122+ data, err := json.Marshal(cfg)
123+ if err != nil {
124+ return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
125+ }
126+ return h.newRawResourceHandler(http.StatusOK, "application/json", data)
127+}
128+
129+// handleAsset serves static files from the embedded filesystem.
130+func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
131+ h.mu.RLock()
132+ hasAssets := h.assets != (embed.FS{})
133+ assetDir := h.assetDir
134+ h.mu.RUnlock()
135+
136+ if !hasAssets {
137+ return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured")
138+ }
139+
140+ relPath, ok := resolveAssetPath(u)
141+ if !ok {
142+ return h.newErrorResourceHandler(http.StatusNotFound, "Page not found")
143+ }
144+
145+ fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
146+ data, err := fs.ReadFile(h.assets, fullPath)
147+ if err != nil {
148+ h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
149+ return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
150+ }
151+
152+ contentType := getMimeType(relPath)
153+ h.logger.Debug().
154+ Str("path", fullPath).
155+ Str("content_type", contentType).
156+ Int("size", len(data)).
157+ Msg("serving asset")
158+
159+ return h.newRawResourceHandler(http.StatusOK, contentType, data)
160+}
161+
162+// resolveAssetPath maps a dumb:// URL to a relative asset path.
163+func resolveAssetPath(u *url.URL) (string, bool) {
164+ if u == nil {
165+ return "", false
166+ }
167+
168+ rootByHost := map[string]string{
169+ homePath: indexHTML,
170+ configPath: "config.html",
171+ webrtcPath: "webrtc.html",
172+ errorPath: "error.html",
173+ }
174+
175+ if root, ok := rootByHost[u.Host]; ok {
176+ path := strings.TrimPrefix(u.Path, "/")
177+ if path == "" {
178+ return root, true
179+ }
180+ // Don't serve API paths as assets.
181+ if strings.HasPrefix(path, "api/") {
182+ return "", false
183+ }
184+ return path, true
185+ }
186+
187+ // Handle opaque URLs like dumb:home.
188+ switch u.Opaque {
189+ case homePath:
190+ return indexHTML, true
191+ case configPath:
192+ return "config.html", true
193+ case webrtcPath:
194+ return "webrtc.html", true
195+ case errorPath:
196+ return "error.html", true
197+ default:
198+ return "", false
199+ }
200+}
201+
202+// getMimeType determines the MIME type for a given file path.
203+func getMimeType(filename string) string {
204+ ext := strings.ToLower(filepath.Ext(filename))
205+
206+ mt := mime.TypeByExtension(ext)
207+ if mt != "" {
208+ return mt
209+ }
210+
211+ switch ext {
212+ case ".js", ".mjs":
213+ return "application/javascript"
214+ case ".css":
215+ return "text/css"
216+ case ".svg":
217+ return "image/svg+xml"
218+ case ".ico":
219+ return "image/x-icon"
220+ case ".woff":
221+ return "font/woff"
222+ case ".woff2":
223+ return "font/woff2"
224+ case ".ttf":
225+ return "font/ttf"
226+ case ".otf":
227+ return "font/otf"
228+ case ".png":
229+ return "image/png"
230+ case ".jpg", ".jpeg":
231+ return "image/jpeg"
232+ case ".gif":
233+ return "image/gif"
234+ case ".webp":
235+ return "image/webp"
236+ case ".json":
237+ return "application/json"
238+ case ".xml":
239+ return "application/xml"
240+ case ".html", ".htm":
241+ return "text/html; charset=utf-8"
242+ default:
243+ return "text/plain"
244+ }
245+}
246+
247+// ---------------------------------------------------------------------------
248+// ResourceHandler implementation
249+// ---------------------------------------------------------------------------
250+
251+// staticResourceHandler serves a fixed byte buffer as a CEF ResourceHandler.
252+type staticResourceHandler struct {
253+ data []byte
254+ contentType string
255+ statusCode int
256+ offset int
257+}
258+
259+func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
260+ return purecef.NewResourceHandler(&staticResourceHandler{
261+ data: data,
262+ contentType: contentType,
263+ statusCode: status,
264+ })
265+}
266+
267+func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
268+ body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, msg)
269+ return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))
270+}
271+
272+func (h *dumbSchemeHandler) newJSONResourceHandler(status int, v any) purecef.ResourceHandler {
273+ data, _ := json.Marshal(v)
274+ return h.newRawResourceHandler(status, "application/json", data)
275+}
276+
277+// Open handles the request immediately (synchronous).
278+func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest unsafe.Pointer, _ purecef.Callback) int32 {
279+ // Set handleRequest = true (1) to indicate we handle it immediately.
280+ *(*int32)(handleRequest) = 1
281+ return 1
282+}
283+
284+// ProcessRequest is deprecated; Open is used instead.
285+func (rh *staticResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Callback) int32 {
286+ return 0
287+}
288+
289+// GetResponseHeaders sets status code, MIME type, and content length.
290+func (rh *staticResourceHandler) GetResponseHeaders(response purecef.Response, responseLength unsafe.Pointer, _ uintptr) {
291+ response.SetStatus(int32(rh.statusCode))
292+ response.SetMimeType(rh.contentType)
293+ *(*int64)(responseLength) = int64(len(rh.data))
294+}
295+
296+// Skip is not used for static content.
297+func (rh *staticResourceHandler) Skip(_ int64, _ unsafe.Pointer, _ purecef.ResourceSkipCallback) int32 {
298+ return 0
299+}
300+
301+// Read copies data into the output buffer.
302+func (rh *staticResourceHandler) Read(
303+ dataOut unsafe.Pointer, bytesToRead int32,
304+ bytesRead unsafe.Pointer, _ purecef.ResourceReadCallback,
305+) int32 {
306+ if rh.offset >= len(rh.data) {
307+ return 0
308+ }
309+
310+ remaining := len(rh.data) - rh.offset
311+ toRead := int(bytesToRead)
312+ if toRead > remaining {
313+ toRead = remaining
314+ }
315+
316+ // Copy data to the output buffer.
317+ dst := unsafe.Slice((*byte)(dataOut), toRead)
318+ copy(dst, rh.data[rh.offset:rh.offset+toRead])
319+ rh.offset += toRead
320+
321+ *(*int32)(bytesRead) = int32(toRead)
322+ return 1
323+}
324+
325+// ReadResponse is deprecated; Read is used instead.
326+func (rh *staticResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ unsafe.Pointer, _ purecef.Callback) int32 {
327+ return 0
328+}
329+
330+// Cancel is a no-op for static content.
331+func (rh *staticResourceHandler) Cancel() {}
332+
333+// ---------------------------------------------------------------------------
334+// Request body reader
335+// ---------------------------------------------------------------------------
336+
337+// readBodyFromHeader extracts the message body from the X-Dumber-Body header.
338+// The JS bridge base64-encodes the JSON body into this header to avoid the
339+// purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported).
340+func readBodyFromHeader(request purecef.Request) []byte {
341+ encoded := request.GetHeaderByName("X-Dumber-Body")
342+ if encoded == "" {
343+ return nil
344+ }
345+ body, err := base64.StdEncoding.DecodeString(encoded)
346+ if err != nil {
347+ return nil
348+ }
349+ return body
350+}
M internal/infrastructure/cef/webview.go +6 -2
@@ -42,6 +42,7 @@ type WebView struct {
4242 pipeline *renderPipeline
4343 input *inputBridge
4444 handlers *handlerSet
45+ findCtrl *cefFindController
4546
4647 // beginFrameTick drives CEF external BeginFrame requests while the GTK
4748 // widget is visible. Access is guarded by mu.
@@ -384,9 +385,12 @@ func (wv *WebView) OpenDevTools() {
384385 // Find
385386 // ---------------------------------------------------------------------------
386387
387-// GetFindController returns nil (Phase 1 stub).
388+// GetFindController returns the find-in-page controller.
388389 func (wv *WebView) GetFindController() port.FindController {
389- return nil
390+ if wv.findCtrl == nil {
391+ return nil
392+ }
393+ return wv.findCtrl
390394 }
391395
392396 // ---------------------------------------------------------------------------
@@ -42,6 +42,7 @@ type WebView struct {
42 pipeline *renderPipeline42 pipeline *renderPipeline
43 input *inputBridge43 input *inputBridge
44 handlers *handlerSet44 handlers *handlerSet
45+ findCtrl *cefFindController
45 46
46 // beginFrameTick drives CEF external BeginFrame requests while the GTK47 // beginFrameTick drives CEF external BeginFrame requests while the GTK
47 // widget is visible. Access is guarded by mu.48 // widget is visible. Access is guarded by mu.
@@ -384,9 +385,12 @@ func (wv *WebView) OpenDevTools() {
384 // Find385 // Find
385 // ---------------------------------------------------------------------------386 // ---------------------------------------------------------------------------
386 387
387-// GetFindController returns nil (Phase 1 stub).388+// GetFindController returns the find-in-page controller.
388 func (wv *WebView) GetFindController() port.FindController {389 func (wv *WebView) GetFindController() port.FindController {
389- return nil390+ if wv.findCtrl == nil {
391+ return nil
392+ }
393+ return wv.findCtrl
390 }394 }
391 395
392 // ---------------------------------------------------------------------------396 // ---------------------------------------------------------------------------
M internal/infrastructure/cef/webview_handlers.go +28 -1
@@ -26,6 +26,7 @@ var (
2626 _ purecef.RequestHandler = (*handlerSet)(nil)
2727 _ purecef.AudioHandler = (*handlerSet)(nil)
2828 _ purecef.ContextMenuHandler = (*handlerSet)(nil)
29+ _ purecef.FindHandler = (*handlerSet)(nil)
2930 )
3031
3132 // ===========================================================================
@@ -49,7 +50,7 @@ func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return n
4950 func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
5051 func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil }
5152 func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
52-func (h *handlerSet) GetFindHandler() purecef.FindHandler { return nil }
53+func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
5354 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
5455 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
5556 func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
@@ -340,6 +341,11 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
340341 // Successful load — reset the consecutive crash counter.
341342 h.wv.crashCount.Store(0)
342343
344+ // Inject scripts and styles after page load.
345+ if h.wv.engine != nil && h.wv.engine.contentInj != nil {
346+ h.wv.engine.contentInj.onLoadEnd(h.wv)
347+ }
348+
343349 h.wv.mu.RLock()
344350 cb := h.wv.callbacks
345351 h.wv.mu.RUnlock()
@@ -423,6 +429,7 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
423429 Msg("cef: OnAfterCreated")
424430 if h.wv.engine != nil {
425431 h.wv.engine.recordBrowserAfterCreated(browser)
432+ h.wv.engine.registerWebView(h.wv)
426433 }
427434 host := browser.GetHost()
428435
@@ -432,6 +439,9 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
432439 uri := h.wv.pendingURI
433440 h.wv.pendingURI = ""
434441 h.wv.input.setHost(host)
442+ if h.wv.findCtrl != nil {
443+ h.wv.findCtrl.setHost(host)
444+ }
435445
436446 // Replay any navigation that was requested before the browser existed.
437447 if uri != "" {
@@ -451,10 +461,16 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool {
451461
452462 // OnBeforeClose fires the OnClose callback.
453463 func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
464+ if h.wv.engine != nil {
465+ h.wv.engine.unregisterWebView(h.wv)
466+ }
454467 h.wv.mu.Lock()
455468 h.wv.browser = nil
456469 h.wv.host = nil
457470 h.wv.input.setHost(nil)
471+ if h.wv.findCtrl != nil {
472+ h.wv.findCtrl.setHost(nil)
473+ }
458474 h.wv.mu.Unlock()
459475 h.wv.scheduleStopBeginFrameLoop()
460476
@@ -579,6 +595,17 @@ func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, _ string) {
579595 h.wv.setAudioPlaying(false)
580596 }
581597
598+// ===========================================================================
599+// FindHandler (1 method)
600+// ===========================================================================
601+
602+// OnFindResult dispatches CEF find results to the WebView's FindController.
603+func (h *handlerSet) OnFindResult(_ purecef.Browser, identifier, count int32, _ *purecef.Rect, activematchordinal, finalupdate int32) {
604+ if h.wv.findCtrl != nil {
605+ h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate)
606+ }
607+}
608+
582609 // cefCursorNames maps CEF cursor types to GDK/CSS cursor names.
583610 var cefCursorNames = map[purecef.CursorType]string{
584611 purecef.CursorTypeCtPointer: "default",
@@ -26,6 +26,7 @@ var (
26 _ purecef.RequestHandler = (*handlerSet)(nil)26 _ purecef.RequestHandler = (*handlerSet)(nil)
27 _ purecef.AudioHandler = (*handlerSet)(nil)27 _ purecef.AudioHandler = (*handlerSet)(nil)
28 _ purecef.ContextMenuHandler = (*handlerSet)(nil)28 _ purecef.ContextMenuHandler = (*handlerSet)(nil)
29+ _ purecef.FindHandler = (*handlerSet)(nil)
29 )30 )
30 31
31 // ===========================================================================32 // ===========================================================================
@@ -49,7 +50,7 @@ func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return n
49 func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }50 func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
50 func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil }51 func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil }
51 func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }52 func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
52-func (h *handlerSet) GetFindHandler() purecef.FindHandler { return nil }53+func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
53 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }54 func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
54 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }55 func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
55 func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }56 func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
@@ -340,6 +341,11 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
340 // Successful load — reset the consecutive crash counter.341 // Successful load — reset the consecutive crash counter.
341 h.wv.crashCount.Store(0)342 h.wv.crashCount.Store(0)
342 343
344+ // Inject scripts and styles after page load.
345+ if h.wv.engine != nil && h.wv.engine.contentInj != nil {
346+ h.wv.engine.contentInj.onLoadEnd(h.wv)
347+ }
348+
343 h.wv.mu.RLock()349 h.wv.mu.RLock()
344 cb := h.wv.callbacks350 cb := h.wv.callbacks
345 h.wv.mu.RUnlock()351 h.wv.mu.RUnlock()
@@ -423,6 +429,7 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
423 Msg("cef: OnAfterCreated")429 Msg("cef: OnAfterCreated")
424 if h.wv.engine != nil {430 if h.wv.engine != nil {
425 h.wv.engine.recordBrowserAfterCreated(browser)431 h.wv.engine.recordBrowserAfterCreated(browser)
432+ h.wv.engine.registerWebView(h.wv)
426 }433 }
427 host := browser.GetHost()434 host := browser.GetHost()
428 435
@@ -432,6 +439,9 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
432 uri := h.wv.pendingURI439 uri := h.wv.pendingURI
433 h.wv.pendingURI = ""440 h.wv.pendingURI = ""
434 h.wv.input.setHost(host)441 h.wv.input.setHost(host)
442+ if h.wv.findCtrl != nil {
443+ h.wv.findCtrl.setHost(host)
444+ }
435 445
436 // Replay any navigation that was requested before the browser existed.446 // Replay any navigation that was requested before the browser existed.
437 if uri != "" {447 if uri != "" {
@@ -451,10 +461,16 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool {
451 461
452 // OnBeforeClose fires the OnClose callback.462 // OnBeforeClose fires the OnClose callback.
453 func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {463 func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
464+ if h.wv.engine != nil {
465+ h.wv.engine.unregisterWebView(h.wv)
466+ }
454 h.wv.mu.Lock()467 h.wv.mu.Lock()
455 h.wv.browser = nil468 h.wv.browser = nil
456 h.wv.host = nil469 h.wv.host = nil
457 h.wv.input.setHost(nil)470 h.wv.input.setHost(nil)
471+ if h.wv.findCtrl != nil {
472+ h.wv.findCtrl.setHost(nil)
473+ }
458 h.wv.mu.Unlock()474 h.wv.mu.Unlock()
459 h.wv.scheduleStopBeginFrameLoop()475 h.wv.scheduleStopBeginFrameLoop()
460 476
@@ -579,6 +595,17 @@ func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, _ string) {
579 h.wv.setAudioPlaying(false)595 h.wv.setAudioPlaying(false)
580 }596 }
581 597
598+// ===========================================================================
599+// FindHandler (1 method)
600+// ===========================================================================
601+
602+// OnFindResult dispatches CEF find results to the WebView's FindController.
603+func (h *handlerSet) OnFindResult(_ purecef.Browser, identifier, count int32, _ *purecef.Rect, activematchordinal, finalupdate int32) {
604+ if h.wv.findCtrl != nil {
605+ h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate)
606+ }
607+}
608+
582 // cefCursorNames maps CEF cursor types to GDK/CSS cursor names.609 // cefCursorNames maps CEF cursor types to GDK/CSS cursor names.
583 var cefCursorNames = map[purecef.CursorType]string{610 var cefCursorNames = map[purecef.CursorType]string{
584 purecef.CursorTypeCtPointer: "default",611 purecef.CursorTypeCtPointer: "default",

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