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, 0, "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, 0, "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, 0)
249 return nil
250}
251
252// onLoadEndForEvent installs scripts for a captured load-end event after
253// revalidating it on the GTK thread. The event identity comes from the
254// OnLoadEnd callback's browser (not mutable WebView state), so events that
255// are provably stale are skipped: a replaced browser identifier (old main
256// frame during process swap) or a superseded intent ID (a newer navigation
257// committed while the callback was queued). The raw committed CEF URL in the
258// event is compared only against the raw committed URL as an additional
259// stale-event guard; it is never compared against the conceptual URI.
260// Ambiguous cases fall back to skipping rather than
261// installing blind: an event captured without a browser cannot prove
262// identity, and the current browser gets its own load-end installation.
263// The installation is pinned to the validated document sequence, so a
264// replacement commit between validation and the CEF UI thread drops the
265// scripts instead of injecting them into the new document. Repeated
266// notifications for the same committed document are coalesced.
267func (ci *contentInjector) onLoadEndForEvent(wv *WebView, event injectionEvent) {
268 if wv == nil || ci == nil || event.browserID == noBrowserID {
269 return
270 }
271 wv.mu.RLock()
272 browser := wv.browser
273 wv.mu.RUnlock()
274 if browser == nil || browser.GetIdentifier() != event.browserID {
275 return
276 }
277 wv.mu.Lock()
278 if wv.browser != browser || wv.pendingIntentID != event.intentID || event.documentSeq == 0 ||
279 wv.documentSeq != event.documentSeq || wv.committedURL != event.committedURL ||
280 wv.installedDocumentSeq == event.documentSeq {
281 wv.mu.Unlock()
282 return
283 }
284 wv.installedDocumentSeq = event.documentSeq
285 wv.mu.Unlock()
286 ci.onLoadEnd(wv, event.documentSeq)
287}
288
289// onLoadEnd is called from the load handler after a page finishes loading.
290// It injects the appropriate scripts based on whether the page is internal.
291// A documentSeq of 0 installs unconditionally (manual refresh paths).
292func (ci *contentInjector) onLoadEnd(wv *WebView, documentSeq uint64) {
293 uri := wv.URI()
294 isInternal := isConceptualInternalURL(uri) || isActualInternalURL(uri)
295
296 ci.mu.RLock()
297 themeCSS := ci.themeCSS
298 findCSS := ci.findHighlightCSS
299 ci.mu.RUnlock()
300
301 // Internal pages get dark mode + message bridge + theme CSS.
302 if isInternal {
303 prefersDark := false
304 if ci.colorResolver != nil {
305 prefersDark = ci.colorResolver.Resolve().PrefersDark
306 }
307 ci.injectDarkModeScript(wv, documentSeq, prefersDark)
308 ci.injectMessageBridgeShim(wv, documentSeq)
309 if themeCSS != "" {
310 ci.injectCSS(wv, documentSeq, "dumber-theme-vars", themeCSS)
311 }
312 }
313
314 // All pages get find highlight CSS if set.
315 if findCSS != "" {
316 ci.injectCSS(wv, documentSeq, "dumber-find-highlight", findCSS)
317 }
318
319 // All pages get custom scrollbar styling with auto-hide.
320 ci.injectCSS(wv, documentSeq, "dumber-scrollbar", scrollbarCSS)
321 wv.RunJavaScriptForDocument(documentSeq, scrollbarAutoHideJS)
322
323 // Clipboard copy/cut, editable focus sync, and synthetic popup proxies still
324 // need a JS bridge in OSR mode while the native renderer bridge remains disabled.
325 bridgeNonce := wv.ensureBridgeNonce()
326 if bridgeNonce == "" {
327 ctx := context.Background()
328 if ci != nil && ci.engine != nil {
329 ctx = ci.engine.currentContext()
330 }
331 logging.FromContext(ctx).Warn().Msg("cef: skipped clipboard bridge injection — nonce generation failed")
332 } else {
333 wv.RunJavaScriptForDocument(documentSeq, buildTrustedPageFetchBridgeJS(bridgeNonce))
334 }
335
336 // Video playback diagnostic — logs video element state changes.
337 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.
338 if ci.videoDiagnosticsEnabled {
339 wv.RunJavaScriptForDocument(documentSeq, videoDiagnosticJS)
340 }
341}
342
343// injectCSS injects a CSS string as a <style> element via JavaScript. The
344// style is pinned to documentSeq when the caller knows the committed document
345// sequence; 0 installs unconditionally.
346func (ci *contentInjector) injectCSS(wv *WebView, documentSeq uint64, id, css string) {
347 escapedID := webutil.EscapeForJSString(id)
348 escaped := webutil.EscapeForJSString(css)
349 script := fmt.Sprintf(`(function(){
350 var el = document.getElementById('%s');
351 if (!el) { el = document.createElement('style'); el.id = '%s'; document.head.appendChild(el); }
352 el.textContent = '%s';
353})();`, escapedID, escapedID, escaped)
354
355 wv.RunJavaScriptForDocument(documentSeq, script)
356}
357
358// injectDarkModeScript sets dark/light class on <html> and patches matchMedia
359// for prefers-color-scheme queries on internal pages.
360func (ci *contentInjector) injectDarkModeScript(wv *WebView, documentSeq uint64, prefersDark bool) {
361 wv.RunJavaScriptForDocument(documentSeq, webutil.DarkModeScript(prefersDark, "__dumber_cef_prefers_dark"))
362}
363
364// injectMessageBridgeShim injects the window.dumber.postMessage JS client shim
365// so internal pages can communicate with Go handlers via fetch.
366// The message body is base64-encoded into the X-Dumber-Body header to work
367// around purego-cef's unexported PostData element wrapper.
368func (ci *contentInjector) injectMessageBridgeShim(wv *WebView, documentSeq uint64) {
369 wv.RunJavaScriptForDocument(documentSeq, MessageBridgeJS)
370}