v0.31.0 bf0756b

content_injector.go RawHistoryFolder

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}

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