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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 40 | 40 | Str("command_line", cmdline). | |
| 41 | 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 | 52 | func (a *dumberApp) GetResourceBundleHandler() purecef.ResourceBundleHandler { return nil } | |
| 45 | 53 | func (a *dumberApp) GetBrowserProcessHandler() purecef.BrowserProcessHandler { return a.bph } | |
| 46 | 54 | 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 | |||
| 87 | 87 | cmdID := item.commandID | |
| 88 | 88 | // clickCb captures popover by reference; this is safe because the | |
| 89 | 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 | 91 | clickCb := func(_ gtk.Button) { | |
| 92 | 92 | selected = true | |
| 93 | 93 | callback.Cont(cmdID, 0) | |
| @@ -87,7 +87,7 @@ func showContextMenu(wv *WebView, items []menuEntry, x, y int32, callback purece | |||||
| 87 | cmdID := item.commandID | 87 | cmdID := item.commandID | ||
| 88 | // clickCb captures popover by reference; this is safe because the | 88 | // 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 = true | 92 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "sync" | |
| 5 | 7 | "sync/atomic" | |
| 6 | 8 | "time" | |
| 9 | + | "unicode/utf8" | |
| 7 | 10 | ||
| 8 | 11 | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | 12 | "github.com/bnema/puregotk/v4/glib" | |
| @@ -23,6 +26,13 @@ type Engine struct { | |||
| 23 | 26 | factory *WebViewFactory | |
| 24 | 27 | 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 | 36 | multiThreadedMessageLoop bool | |
| 27 | 37 | manualPumpInterval int64 | |
| 28 | 38 | ||
| @@ -59,8 +69,11 @@ func (e *Engine) Pool() port.WebViewPool { | |||
| 59 | 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 | 73 | func (e *Engine) ContentInjector() port.ContentInjector { | |
| 74 | + | if e.contentInj != nil { | |
| 75 | + | return e.contentInj | |
| 76 | + | } | |
| 64 | 77 | return &noopContentInjector{} | |
| 65 | 78 | } | |
| 66 | 79 | ||
| @@ -79,8 +92,29 @@ func (e *Engine) FaviconDatabase() port.FaviconDatabase { | |||
| 79 | 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 | 116 | // internalSchemePath is the URI scheme used for internal app resources. | |
| 83 | - | const internalSchemePath = "cef://dumber/" | |
| 117 | + | const internalSchemePath = "dumb://" | |
| 84 | 118 | ||
| 85 | 119 | // InternalSchemePath returns the URI scheme used for internal app resources. | |
| 86 | 120 | func (e *Engine) InternalSchemePath() string { | |
| @@ -95,13 +129,70 @@ func (e *Engine) Close() error { | |||
| 95 | 129 | return nil | |
| 96 | 130 | } | |
| 97 | 131 | ||
| 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") | |
| 100 | 150 | return nil | |
| 101 | 151 | } | |
| 102 | 152 | ||
| 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") | |
| 105 | 196 | return nil | |
| 106 | 197 | } | |
| 107 | 198 | ||
| @@ -138,6 +229,9 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate) | |||
| 138 | 229 | // SetHandlerContext sets the base context for message handler dispatch. | |
| 139 | 230 | func (e *Engine) SetHandlerContext(ctx context.Context) { | |
| 140 | 231 | e.ctx = ctx | |
| 232 | + | if e.messageRouter != nil { | |
| 233 | + | e.messageRouter.SetBaseContext(ctx) | |
| 234 | + | } | |
| 141 | 235 | } | |
| 142 | 236 | ||
| 143 | 237 | 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 *WebViewFactory | 26 | factory *WebViewFactory | ||
| 24 | pool *WebViewPool | 27 | 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 bool | 36 | multiThreadedMessageLoop bool | ||
| 27 | manualPumpInterval int64 | 37 | 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 nil | 129 | 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 nil | 150 | 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 nil | 196 | 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 = ctx | 231 | e.ctx = ctx | ||
| 232 | + | if e.messageRouter != nil { | |||
| 233 | + | e.messageRouter.SetBaseContext(ctx) | |||
| 234 | + | } | |||
| 141 | } | 235 | } | ||
| 142 | 236 | ||||
| 143 | const browserCreateStallWarnMS int64 = 1500 | 237 | const browserCreateStallWarnMS int64 = 1500 | ||
M internal/infrastructure/cef/engine_init.go +87 -83
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/puregotk/v4/gdk" | |
| 14 | 14 | "github.com/rs/zerolog" | |
| 15 | 15 | ||
| 16 | + | "github.com/bnema/dumber/assets" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 17 | 18 | "github.com/bnema/dumber/internal/logging" | |
| 18 | 19 | ) | |
| @@ -22,26 +23,47 @@ const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | |||
| 22 | 23 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | |
| 23 | 24 | func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) { | |
| 24 | 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 | 26 | cleanStaleSingletonLocks(logger) | |
| 30 | 27 | ||
| 31 | - | // Resolve pump mode from config (with defaults). | |
| 32 | 28 | multiThreaded := cfg.CEFMultiThreadedMessageLoop() | |
| 33 | 29 | manualPumpMs := cfg.CEFManualPumpIntervalMs() | |
| 34 | 30 | windowlessFrameRate := cfg.CEFWindowlessFrameRate() | |
| 35 | - | ||
| 36 | 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 | 65 | 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() | |
| 45 | 67 | settings.ExternalMessagePump = false | |
| 46 | 68 | if cfg.CEFDir != "" { | |
| 47 | 69 | settings.CEFDir = cfg.CEFDir | |
| @@ -63,103 +85,47 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) | |||
| 63 | 85 | if cfg.LogSeverity != 0 { | |
| 64 | 86 | settings.LogSeverity = cfg.LogSeverity | |
| 65 | 87 | } | |
| 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 != "" { | |
| 76 | 89 | settings.BrowserSubprocessPath = helperPath | |
| 77 | 90 | logger.Debug().Str("path", helperPath).Msg("cef: using subprocess helper") | |
| 78 | 91 | } else { | |
| 79 | 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, | |
| 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 { | |
| 110 | 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 | 100 | logger.Debug().Msg("cef: calling Init") | |
| 116 | 101 | 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) | |
| 119 | 103 | } | |
| 120 | - | os.Args = savedArgs | |
| 121 | 104 | logger.Debug().Msg("cef: Init returned OK") | |
| 122 | 105 | } else { | |
| 123 | - | // Create the CEF App with a BrowserProcessHandler that drives the | |
| 124 | - | // adaptive message pump via OnScheduleMessagePumpWork. | |
| 125 | 106 | app := newDumberApp(eng) | |
| 126 | - | ||
| 127 | 107 | logger.Debug().Msg("cef: calling InitWithApp") | |
| 128 | 108 | 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) | |
| 131 | 110 | } | |
| 132 | - | os.Args = savedArgs | |
| 133 | 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 | 121 | gl, err := newGLLoader() | |
| 138 | 122 | if err != nil { | |
| 139 | 123 | purecef.Shutdown() | |
| 140 | 124 | return nil, fmt.Errorf("GL loader: %w", err) | |
| 141 | 125 | } | |
| 142 | 126 | ||
| 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) | |
| 161 | 128 | ||
| 162 | - | // 5. Create factory + pool and wire them into the engine. | |
| 163 | 129 | factory := newWebViewFactory(eng, gl, webViewFactoryOptions{ | |
| 164 | 130 | scale: scale, | |
| 165 | 131 | windowlessFrameRate: windowlessFrameRate, | |
| @@ -171,10 +137,48 @@ func NewEngine(ctx context.Context, cfg config.CEFEngineConfig) (*Engine, error) | |||
| 171 | 137 | eng.gl = gl | |
| 172 | 138 | eng.factory = factory | |
| 173 | 139 | 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 | 152 | 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 | 182 | // appendIfMissing appends flag to args if it's not already present. | |
| 179 | 183 | func appendIfMissing(args []string, flag string) []string { | |
| 180 | 184 | 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 = multiThreaded | 66 | + | 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 = false | 67 | settings.ExternalMessagePump = false | ||
| 46 | if cfg.CEFDir != "" { | 68 | if cfg.CEFDir != "" { | ||
| 47 | settings.CEFDir = cfg.CEFDir | 69 | 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.LogSeverity | 86 | 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 = helperPath | 89 | 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 = savedArgs | 102 | + | 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 = savedArgs | 109 | + | 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 = gl | 137 | eng.gl = gl | ||
| 172 | eng.factory = factory | 138 | eng.factory = factory | ||
| 173 | eng.pool = pool | 139 | 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, nil | 152 | 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) { | |||
| 81 | 81 | enableContextMenuHandler: f.enableContextMenuHandler, | |
| 82 | 82 | } | |
| 83 | 83 | wv.handlers = handlers | |
| 84 | + | wv.findCtrl = newFindController() | |
| 84 | 85 | ||
| 85 | 86 | input := newInputBridge(f.scale) | |
| 86 | 87 | 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 = handlers | 83 | 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) { | |||
| 72 | 72 | func (ib *inputBridge) attachTo(glArea *gtk.GLArea) { | |
| 73 | 73 | // Motion (mouse move + enter/leave) | |
| 74 | 74 | 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) | |
| 77 | 78 | } | |
| 78 | 79 | motion.ConnectMotion(&motionCb) | |
| 79 | 80 | ||
| 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) | |
| 82 | 84 | } | |
| 83 | 85 | motion.ConnectLeave(&leaveCb) | |
| 84 | 86 | ||
| @@ -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 { | |||
| 42 | 42 | pipeline *renderPipeline | |
| 43 | 43 | input *inputBridge | |
| 44 | 44 | handlers *handlerSet | |
| 45 | + | findCtrl *cefFindController | |
| 45 | 46 | ||
| 46 | 47 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | |
| 47 | 48 | // widget is visible. Access is guarded by mu. | |
| @@ -384,9 +385,12 @@ func (wv *WebView) OpenDevTools() { | |||
| 384 | 385 | // Find | |
| 385 | 386 | // --------------------------------------------------------------------------- | |
| 386 | 387 | ||
| 387 | - | // GetFindController returns nil (Phase 1 stub). | |
| 388 | + | // GetFindController returns the find-in-page controller. | |
| 388 | 389 | func (wv *WebView) GetFindController() port.FindController { | |
| 389 | - | return nil | |
| 390 | + | if wv.findCtrl == nil { | |
| 391 | + | return nil | |
| 392 | + | } | |
| 393 | + | return wv.findCtrl | |
| 390 | 394 | } | |
| 391 | 395 | ||
| 392 | 396 | // --------------------------------------------------------------------------- | |
| @@ -42,6 +42,7 @@ type WebView struct { | |||||
| 42 | pipeline *renderPipeline | 42 | pipeline *renderPipeline | ||
| 43 | input *inputBridge | 43 | input *inputBridge | ||
| 44 | handlers *handlerSet | 44 | handlers *handlerSet | ||
| 45 | + | findCtrl *cefFindController | |||
| 45 | 46 | ||||
| 46 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | 47 | // 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 | // Find | 385 | // 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 nil | 390 | + | 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 ( | |||
| 26 | 26 | _ purecef.RequestHandler = (*handlerSet)(nil) | |
| 27 | 27 | _ purecef.AudioHandler = (*handlerSet)(nil) | |
| 28 | 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 | 50 | func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h } | |
| 50 | 51 | func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil } | |
| 51 | 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 | 54 | func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil } | |
| 54 | 55 | func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil } | |
| 55 | 56 | func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil } | |
| @@ -340,6 +341,11 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu | |||
| 340 | 341 | // Successful load — reset the consecutive crash counter. | |
| 341 | 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 | 349 | h.wv.mu.RLock() | |
| 344 | 350 | cb := h.wv.callbacks | |
| 345 | 351 | h.wv.mu.RUnlock() | |
| @@ -423,6 +429,7 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 423 | 429 | Msg("cef: OnAfterCreated") | |
| 424 | 430 | if h.wv.engine != nil { | |
| 425 | 431 | h.wv.engine.recordBrowserAfterCreated(browser) | |
| 432 | + | h.wv.engine.registerWebView(h.wv) | |
| 426 | 433 | } | |
| 427 | 434 | host := browser.GetHost() | |
| 428 | 435 | ||
| @@ -432,6 +439,9 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 432 | 439 | uri := h.wv.pendingURI | |
| 433 | 440 | h.wv.pendingURI = "" | |
| 434 | 441 | h.wv.input.setHost(host) | |
| 442 | + | if h.wv.findCtrl != nil { | |
| 443 | + | h.wv.findCtrl.setHost(host) | |
| 444 | + | } | |
| 435 | 445 | ||
| 436 | 446 | // Replay any navigation that was requested before the browser existed. | |
| 437 | 447 | if uri != "" { | |
| @@ -451,10 +461,16 @@ func (h *handlerSet) DoClose(_ purecef.Browser) bool { | |||
| 451 | 461 | ||
| 452 | 462 | // OnBeforeClose fires the OnClose callback. | |
| 453 | 463 | func (h *handlerSet) OnBeforeClose(_ purecef.Browser) { | |
| 464 | + | if h.wv.engine != nil { | |
| 465 | + | h.wv.engine.unregisterWebView(h.wv) | |
| 466 | + | } | |
| 454 | 467 | h.wv.mu.Lock() | |
| 455 | 468 | h.wv.browser = nil | |
| 456 | 469 | h.wv.host = nil | |
| 457 | 470 | h.wv.input.setHost(nil) | |
| 471 | + | if h.wv.findCtrl != nil { | |
| 472 | + | h.wv.findCtrl.setHost(nil) | |
| 473 | + | } | |
| 458 | 474 | h.wv.mu.Unlock() | |
| 459 | 475 | h.wv.scheduleStopBeginFrameLoop() | |
| 460 | 476 | ||
| @@ -579,6 +595,17 @@ func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, _ string) { | |||
| 579 | 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 | 609 | // cefCursorNames maps CEF cursor types to GDK/CSS cursor names. | |
| 583 | 610 | var cefCursorNames = map[purecef.CursorType]string{ | |
| 584 | 611 | 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.callbacks | 350 | 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.pendingURI | 439 | 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 = nil | 468 | h.wv.browser = nil | ||
| 456 | h.wv.host = nil | 469 | 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", | ||