1package cef
2
3import (
4 "context"
5 "fmt"
6 "sync"
7
8 "github.com/bnema/dumber/internal/infrastructure/webutil"
9
10 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/logging"
12)
13
14// Compile-time interface check.
15var _ port.ContentInjector = (*contentInjector)(nil)
16
17// scrollbarCSS styles the scrollbar with auto-hide behavior: invisible by
18// default, fades in on scroll, widens on hover, fades out after 1s idle.
19// Uses --primary theme color for the thumb.
20const scrollbarCSS = `
21::-webkit-scrollbar {
22 width: 6px;
23 height: 6px;
24}
25::-webkit-scrollbar-track {
26 background: transparent;
27}
28::-webkit-scrollbar-thumb {
29 background: transparent;
30 border-radius: 3px;
31}
32.dumber-scrolling ::-webkit-scrollbar-thumb {
33 background: var(--primary, rgba(128, 128, 128, 0.4));
34}
35.dumber-scrolling ::-webkit-scrollbar:hover {
36 width: 10px;
37 height: 10px;
38}
39.dumber-scrolling ::-webkit-scrollbar-track:hover {
40 background: rgba(128, 128, 128, 0.1);
41}
42.dumber-scrolling ::-webkit-scrollbar-thumb:hover {
43 background: var(--primary, rgba(128, 128, 128, 0.6));
44}
45::-webkit-scrollbar-corner {
46 background: transparent;
47}
48`
49
50// scrollbarAutoHideJS adds/removes the .dumber-scrolling class on <html>
51// on scroll activity, with a 1s fade-out timeout after scrolling stops.
52const scrollbarAutoHideJS = `(function(){
53 var t, el = document.documentElement;
54 function show() {
55 el.classList.add('dumber-scrolling');
56 clearTimeout(t);
57 t = setTimeout(function(){ el.classList.remove('dumber-scrolling'); }, 1000);
58 }
59 window.addEventListener('scroll', show, {passive:true,capture:true});
60 window.addEventListener('wheel', show, {passive:true});
61 el.addEventListener('mouseenter', function(){
62 if(el.scrollHeight > el.clientHeight) show();
63 });
64})();`
65
66// videoDiagnosticJS monitors all <video> elements on the page and logs their
67// state changes to the console. This helps diagnose playback issues in CEF OSR.
68// TODO: remove once video playback is stable.
69const videoDiagnosticJS = `(function(){
70 var tag = '[VIDEO-DIAG]';
71 var events = ['loadstart','loadedmetadata','loadeddata','canplay','canplaythrough',
72 'play','playing','pause','waiting','stalled','error','abort','emptied','suspend'];
73
74 function monitor(v, label) {
75 if (v._dumberDiag) return;
76 v._dumberDiag = true;
77 console.warn(tag, label, 'src:', (v.src||'').substring(0,80),
78 'currentSrc:', (v.currentSrc||'').substring(0,80),
79 'readyState:', v.readyState, 'networkState:', v.networkState,
80 'preload:', v.preload, 'autoplay:', v.autoplay);
81 events.forEach(function(evt) {
82 v.addEventListener(evt, function() {
83 var err = v.error ? ('code='+v.error.code+' msg='+v.error.message) : 'none';
84 console.warn(tag, evt, label, 'ready:', v.readyState, 'net:', v.networkState,
85 'paused:', v.paused, 'error:', err, 'src:', (v.currentSrc||v.src||'').substring(0,80));
86 });
87 });
88 }
89
90 // Recursively scan a root (document or shadowRoot) for video elements.
91 function scanRoot(root, label) {
92 root.querySelectorAll('video').forEach(function(v, i) { monitor(v, label+'#'+i); });
93 // Pierce shadow DOMs.
94 root.querySelectorAll('*').forEach(function(el) {
95 if (el.shadowRoot) {
96 scanRoot(el.shadowRoot, label+'>'+el.tagName.toLowerCase());
97 // Observe inside shadow root too.
98 observeRoot(el.shadowRoot, label+'>'+el.tagName.toLowerCase());
99 }
100 });
101 }
102
103 function observeRoot(root, label) {
104 new MutationObserver(function(muts) {
105 muts.forEach(function(m) {
106 m.addedNodes.forEach(function(n) {
107 if (n.nodeName === 'VIDEO') monitor(n, label+'-dyn');
108 if (n.querySelectorAll) {
109 n.querySelectorAll('video').forEach(function(v) { monitor(v, label+'-nested'); });
110 }
111 // New element might have shadow root.
112 if (n.shadowRoot) {
113 scanRoot(n.shadowRoot, label+'>'+n.tagName.toLowerCase());
114 observeRoot(n.shadowRoot, label+'>'+n.tagName.toLowerCase());
115 }
116 });
117 });
118 }).observe(root, {childList:true, subtree:true});
119 }
120
121 // Patch attachShadow to automatically observe new shadow roots.
122 // Use a marker to avoid double-patching.
123 var origAttach = Element.prototype.__dumberOrigAttachShadow || Element.prototype.attachShadow;
124 if (!Element.prototype.__dumberPatched) {
125 Element.prototype.__dumberOrigAttachShadow = origAttach;
126 Element.prototype.__dumberPatched = true;
127 }
128 Element.prototype.attachShadow = function(opts) {
129 var sr = origAttach.call(this, opts);
130 console.warn(tag, 'shadowRoot attached on', this.tagName.toLowerCase());
131 observeRoot(sr, 'shadow>'+this.tagName.toLowerCase());
132 return sr;
133 };
134
135 // Also intercept MediaSource to trace usage.
136 if (window.MediaSource) {
137 var origAddSB = MediaSource.prototype.addSourceBuffer;
138 MediaSource.prototype.addSourceBuffer = function(mime) {
139 console.warn(tag, 'MediaSource.addSourceBuffer:', mime,
140 'supported:', MediaSource.isTypeSupported(mime));
141 try { return origAddSB.call(this, mime); }
142 catch(e) { console.error(tag, 'addSourceBuffer FAILED:', e.message, 'mime:', mime); throw e; }
143 };
144 var origURL = URL.createObjectURL;
145 URL.createObjectURL = function(obj) {
146 var url = origURL.call(this, obj);
147 if (obj instanceof MediaSource) {
148 console.warn(tag, 'URL.createObjectURL(MediaSource):', url.substring(0,80));
149 }
150 return url;
151 };
152 }
153
154 // Initial scan.
155 scanRoot(document, 'doc');
156
157 // Observe document for new elements.
158 observeRoot(document.documentElement, 'doc');
159
160 console.warn(tag, 'diagnostic active, MSE:', !!window.MediaSource,
161 'h264:', MediaSource ? MediaSource.isTypeSupported('video/mp4; codecs="avc1.4d401e"') : 'n/a',
162 'aac:', MediaSource ? MediaSource.isTypeSupported('audio/mp4; codecs="mp4a.40.2"') : 'n/a');
163})();`
164
165// contentInjector implements port.ContentInjector for the CEF engine.
166// It stores CSS strings and injects them into webviews via ExecuteJavaScript.
167// Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
168// fires on the CEF IO thread.
169type contentInjector struct {
170 mu sync.RWMutex
171 themeCSS string
172 findHighlightCSS string
173 engine *Engine
174 colorResolver port.ColorSchemeResolver
175 videoDiagnosticsEnabled bool
176}
177
178// setColorResolver updates the color scheme resolver used for dark mode detection.
179func (ci *contentInjector) setColorResolver(resolver port.ColorSchemeResolver) {
180 ci.mu.Lock()
181 defer ci.mu.Unlock()
182 ci.colorResolver = resolver
183}
184
185// newContentInjector creates a content injector wired to the given engine.
186// Video diagnostics are enabled when DUMBER_VIDEO_DIAG=1 is set.
187func newContentInjector(engine *Engine, resolver port.ColorSchemeResolver) *contentInjector {
188 diagEnabled := envBoolEnabled("DUMBER_VIDEO_DIAG")
189 return &contentInjector{
190 engine: engine,
191 colorResolver: resolver,
192 videoDiagnosticsEnabled: diagEnabled,
193 }
194}
195
196// InjectThemeCSS stores the theme CSS and broadcasts it to all active webviews.
197func (ci *contentInjector) InjectThemeCSS(ctx context.Context, css string) error {
198 log := logging.FromContext(ctx).With().Str("component", "cef-content-injector").Logger()
199
200 ci.mu.Lock()
201 ci.themeCSS = css
202 ci.mu.Unlock()
203
204 log.Debug().Int("css_len", len(css)).Msg("theme CSS set, broadcasting to active webviews")
205
206 // Broadcast to all active webviews.
207 ci.engine.activeWebViews.Range(func(_, value any) bool {
208 if wv, ok := value.(*WebView); ok {
209 ci.injectCSS(wv, "dumber-theme-vars", css)
210 }
211 return true
212 })
213 return nil
214}
215
216// InjectFindHighlightCSS stores the find highlight CSS and broadcasts it.
217func (ci *contentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
218 log := logging.FromContext(ctx).With().Str("component", "cef-content-injector").Logger()
219
220 ci.mu.Lock()
221 ci.findHighlightCSS = css
222 ci.mu.Unlock()
223
224 log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set, broadcasting to active webviews")
225
226 ci.engine.activeWebViews.Range(func(_, value any) bool {
227 if wv, ok := value.(*WebView); ok {
228 ci.injectCSS(wv, "dumber-find-highlight", css)
229 }
230 return true
231 })
232 return nil
233}
234
235// RefreshScripts re-injects all scripts into a specific webview.
236func (ci *contentInjector) RefreshScripts(ctx context.Context, wv port.WebView) error {
237 log := logging.FromContext(ctx).With().Str("component", "cef-content-injector").Logger()
238 if wv == nil {
239 log.Debug().Msg("RefreshScripts: nil webview")
240 return nil
241 }
242 cefWV, ok := wv.(*WebView)
243 if !ok {
244 log.Debug().Msg("RefreshScripts: webview is not *cef.WebView")
245 return nil
246 }
247
248 ci.onLoadEnd(cefWV)
249 return nil
250}
251
252// onLoadEnd is called from the load handler after a page finishes loading.
253// It injects the appropriate scripts based on whether the page is internal.
254func (ci *contentInjector) onLoadEnd(wv *WebView) {
255 uri := wv.URI()
256 isInternal := isConceptualInternalURL(uri) || isActualInternalURL(uri)
257
258 ci.mu.RLock()
259 themeCSS := ci.themeCSS
260 findCSS := ci.findHighlightCSS
261 ci.mu.RUnlock()
262
263 // Internal pages get dark mode + message bridge + theme CSS.
264 if isInternal {
265 prefersDark := false
266 if ci.colorResolver != nil {
267 prefersDark = ci.colorResolver.Resolve().PrefersDark
268 }
269 ci.injectDarkModeScript(wv, prefersDark)
270 ci.injectMessageBridgeShim(wv)
271 if themeCSS != "" {
272 ci.injectCSS(wv, "dumber-theme-vars", themeCSS)
273 }
274 }
275
276 // All pages get find highlight CSS if set.
277 if findCSS != "" {
278 ci.injectCSS(wv, "dumber-find-highlight", findCSS)
279 }
280
281 // All pages get custom scrollbar styling with auto-hide.
282 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
283 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
284
285 // Clipboard copy/cut, editable focus sync, and synthetic popup proxies still
286 // need a JS bridge in OSR mode while the native renderer bridge remains disabled.
287 bridgeNonce := wv.ensureBridgeNonce()
288 if bridgeNonce == "" {
289 ctx := context.Background()
290 if ci != nil && ci.engine != nil {
291 ctx = ci.engine.currentContext()
292 }
293 logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed")
294 } else {
295 wv.RunJavaScript(context.Background(), buildTrustedPageFetchBridgeJS(bridgeNonce))
296 }
297
298 // Video playback diagnostic — logs video element state changes.
299 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.
300 if ci.videoDiagnosticsEnabled {
301 wv.RunJavaScript(context.Background(), videoDiagnosticJS)
302 }
303}
304
305// injectCSS injects a CSS string as a <style> element via JavaScript.
306func (ci *contentInjector) injectCSS(wv *WebView, id, css string) {
307 escapedID := webutil.EscapeForJSString(id)
308 escaped := webutil.EscapeForJSString(css)
309 script := fmt.Sprintf(`(function(){
310 var el = document.getElementById('%s');
311 if (!el) { el = document.createElement('style'); el.id = '%s'; document.head.appendChild(el); }
312 el.textContent = '%s';
313})();`, escapedID, escapedID, escaped)
314
315 wv.RunJavaScript(context.Background(), script)
316}
317
318// injectDarkModeScript sets dark/light class on <html> and patches matchMedia
319// for prefers-color-scheme queries on internal pages.
320func (ci *contentInjector) injectDarkModeScript(wv *WebView, prefersDark bool) {
321 wv.RunJavaScript(context.Background(), webutil.DarkModeScript(prefersDark, "__dumber_cef_prefers_dark"))
322}
323
324// injectMessageBridgeShim injects the window.dumber.postMessage JS client shim
325// so internal pages can communicate with Go handlers via fetch.
326// The message body is base64-encoded into the X-Dumber-Body header to work
327// around purego-cef's unexported PostData element wrapper.
328func (ci *contentInjector) injectMessageBridgeShim(wv *WebView) {
329 wv.RunJavaScript(context.Background(), MessageBridgeJS)
330}