v0.32.0 0617278

content_injector.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close