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