1package webkit
2
3import (
4 "context"
5 "fmt"
6 "sort"
7 "strings"
8
9 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/puregotk-webkit/webkit"
13)
14
15const (
16 // ScriptWorldName is the isolated world used for the injected UI.
17 ScriptWorldName = "dumber"
18 // MessageHandlerName is the name of the script message handler registered with WebKit.
19 MessageHandlerName = "dumber"
20)
21
22// themeCSSScript injects theme CSS into the page.
23// The %s placeholder is replaced with CSS text (newlines escaped as \n).
24const themeCSSScript = `(function() {
25 var style = document.createElement('style');
26 style.setAttribute('data-dumber-theme-vars', '');
27 style.textContent = '%s';
28 (document.head || document.documentElement).appendChild(style);
29})();`
30
31// internalDarkModeScriptTemplate is injected ONLY on internal pages (dumb://*).
32// It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia
33// for JS-based dark mode detection in WebUI components.
34//
35// NOTE: This script is NOT injected on external pages. External pages receive
36// dark mode preference via libadwaita's StyleManager, which WebKit respects
37// for the native prefers-color-scheme media query.
38//
39// The matchMedia patch handles various query formats:
40// - (prefers-color-scheme: dark)
41// - (prefers-color-scheme:dark) -- no space
42// - screen and (prefers-color-scheme: dark)
43const internalDarkModeScriptTemplate = `(function() {
44 var prefersDark = %t;
45 window.__dumber_gtk_prefers_dark = prefersDark;
46 var originalMatchMedia = window.matchMedia.bind(window);
47
48 // Apply dark/light class to document element for Tailwind CSS theming
49 if (prefersDark) {
50 document.documentElement.classList.add('dark');
51 document.documentElement.classList.remove('light');
52 } else {
53 document.documentElement.classList.add('light');
54 document.documentElement.classList.remove('dark');
55 }
56
57 // Helper: Check if query is a prefers-color-scheme query
58 function isColorSchemeQuery(query, scheme) {
59 if (typeof query !== 'string') return false;
60 var normalized = query.replace(/\s+/g, '').toLowerCase();
61 return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
62 }
63
64 // Create a fake MediaQueryList that implements the full interface
65 function createFakeMediaQueryList(query, matches) {
66 var listeners = [];
67 var onchangeHandler = null;
68
69 return {
70 matches: matches,
71 media: query,
72 get onchange() { return onchangeHandler; },
73 set onchange(fn) { onchangeHandler = fn; },
74 addListener: function(cb) {
75 if (typeof cb === 'function') listeners.push(cb);
76 },
77 removeListener: function(cb) {
78 var idx = listeners.indexOf(cb);
79 if (idx !== -1) listeners.splice(idx, 1);
80 },
81 addEventListener: function(type, cb) {
82 if (type === 'change' && typeof cb === 'function') {
83 listeners.push(cb);
84 }
85 },
86 removeEventListener: function(type, cb) {
87 if (type === 'change') {
88 var idx = listeners.indexOf(cb);
89 if (idx !== -1) listeners.splice(idx, 1);
90 }
91 },
92 dispatchEvent: function(event) {
93 for (var i = 0; i < listeners.length; i++) {
94 try { listeners[i](event); } catch (e) {}
95 }
96 if (onchangeHandler) {
97 try { onchangeHandler(event); } catch (e) {}
98 }
99 return true;
100 }
101 };
102 }
103
104 // Patch matchMedia for prefers-color-scheme queries
105 window.matchMedia = function(query) {
106 if (isColorSchemeQuery(query, 'dark')) {
107 return createFakeMediaQueryList(query, prefersDark);
108 }
109 if (isColorSchemeQuery(query, 'light')) {
110 return createFakeMediaQueryList(query, !prefersDark);
111 }
112 return originalMatchMedia(query);
113 };
114})();`
115
116// internalPageAllowList restricts script injection to internal dumb:// pages only.
117var internalPageAllowList = []string{"dumb://*"}
118
119// autoCopySelectionScript is injected to enable auto-copy on text selection (zellij-style).
120// It listens for selection changes, debounces them, and posts to the Go message handler.
121// Skips selections in input fields/textareas and requires minimum 2 characters.
122const autoCopySelectionScript = `(function() {
123 var debounceTimer = null;
124 var MIN_LENGTH = 2;
125
126 document.addEventListener('selectionchange', function() {
127 // Skip if selection is inside input/textarea/contenteditable
128 var activeEl = document.activeElement;
129 if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) {
130 return;
131 }
132
133 clearTimeout(debounceTimer);
134 debounceTimer = setTimeout(function() {
135 var text = window.getSelection().toString().trim();
136 if (text.length >= MIN_LENGTH && window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) {
137 window.webkit.messageHandlers.dumber.postMessage({
138 type: 'auto_copy_selection',
139 payload: { text: text }
140 });
141 }
142 }, 300);
143 });
144})();`
145
146// accentDetectionScript is built at init from entity.AccentMap so the JS
147// filter stays in sync with the Go-side accent table.
148var accentDetectionScript string
149
150func init() {
151 // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])"
152 keys := make([]string, 0, len(entity.AccentMap))
153 for k := range entity.AccentMap {
154 keys = append(keys, fmt.Sprintf("'%c'", k))
155 }
156 sort.Strings(keys)
157
158 accentDetectionScript = fmt.Sprintf(`(function() {
159 'use strict';
160 const accentKeys = new Set([%s]);
161 let pressedKey = null;
162
163 function isEditableTarget(target) {
164 const el = target instanceof Element ? target : null;
165 if (!el) return false;
166 if (el.closest('[contenteditable=""], [contenteditable="true"]')) return true;
167 if (el.closest('textarea')) return true;
168 const input = el.closest('input');
169 if (!input) return false;
170 return !/^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/i.test(input.type);
171 }
172
173 document.addEventListener('keydown', function(e) {
174 if (!isEditableTarget(e.target)) return;
175 if (e.ctrlKey || e.altKey || e.metaKey) return;
176 const key = e.key.toLowerCase();
177 if (key.length !== 1 || !accentKeys.has(key)) return;
178 pressedKey = key;
179 window.webkit.messageHandlers.dumber.postMessage({
180 type: 'accent_key_press',
181 payload: { char: key, shift: e.shiftKey }
182 });
183 }, true);
184
185 document.addEventListener('keyup', function(e) {
186 if (!isEditableTarget(e.target)) return;
187 const key = e.key.toLowerCase();
188 if (key === pressedKey) {
189 pressedKey = null;
190 window.webkit.messageHandlers.dumber.postMessage({
191 type: 'accent_key_release',
192 payload: { char: key }
193 });
194 }
195 }, true);
196
197 document.addEventListener('focusin', function(e) {
198 if (isEditableTarget(e.target)) {
199 window.__dumber_lastEditableEl = e.target;
200 }
201 }, true);
202})();`, strings.Join(keys, ","))
203}
204
205// accentDetectionInjectionMode controls which frames receive the accent detection script.
206// Set to AllFrames so the script runs in iframes as well as the top-level document.
207const accentDetectionInjectionMode = webkit.UserContentInjectAllFramesValue
208
209// webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names.
210// Some pages gate support on window.RTCPeerConnection and report false negatives
211// when only webkit-prefixed constructors are present.
212const webRTCCompatScript = `(function() {
213 if (!window.RTCPeerConnection && window.webkitRTCPeerConnection) {
214 window.RTCPeerConnection = window.webkitRTCPeerConnection;
215 }
216 if (!window.RTCSessionDescription && window.webkitRTCSessionDescription) {
217 window.RTCSessionDescription = window.webkitRTCSessionDescription;
218 }
219 if (!window.RTCIceCandidate && window.webkitRTCIceCandidate) {
220 window.RTCIceCandidate = window.webkitRTCIceCandidate;
221 }
222})();`
223
224func buildWebRTCCompatScript() string {
225 return webRTCCompatScript
226}
227
228// ContentInjector encapsulates script injection into WebViews.
229// It injects dark mode detection scripts for internal pages (dumb://)
230// and theme CSS variables for WebUI styling.
231// External pages receive dark mode preference via libadwaita's StyleManager.
232// Implements port.ContentInjector interface.
233type ContentInjector struct {
234 colorResolver port.ColorSchemeResolver
235 themeCSSVars string // CSS custom property declarations for WebUI
236 findCSS string // CSS for find-in-page highlight styling
237 autoCopyConfigGetter func() bool // Dynamic getter for auto-copy config
238}
239
240// NewContentInjector creates a new injector instance.
241// The resolver is used to dynamically determine dark mode preference.
242func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector {
243 return &ContentInjector{
244 colorResolver: resolver,
245 }
246}
247
248// SetAutoCopyConfigGetter sets the function to dynamically check if auto-copy is enabled.
249// This is called during script injection to determine whether to inject the selection listener.
250func (ci *ContentInjector) SetAutoCopyConfigGetter(getter func() bool) {
251 ci.autoCopyConfigGetter = getter
252}
253
254// InjectThemeCSS stores CSS variables for injection into internal pages.
255// Implements port.ContentInjector interface.
256// The CSS will be injected when InjectScripts is called on WebView creation.
257func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error {
258 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
259 ci.themeCSSVars = css
260 log.Debug().Int("css_len", len(css)).Msg("theme CSS vars set for injection")
261 return nil
262}
263
264// InjectFindHighlightCSS stores CSS for find-in-page highlight styling.
265func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
266 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
267 ci.findCSS = css
268 log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set for injection")
269 return nil
270}
271
272// PrefersDark returns the current dark mode preference from the resolver.
273func (ci *ContentInjector) PrefersDark() bool {
274 return ci.colorResolver.Resolve().PrefersDark
275}
276
277// InjectScripts adds scripts to the given content manager.
278// For internal pages (dumb://*):
279// - Injects dark mode detection (class on <html>, matchMedia patch)
280// - Injects theme CSS variables for WebUI styling
281//
282// For external pages:
283// - Only injects find highlight CSS
284// - Dark mode is handled natively via libadwaita's StyleManager
285func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
286 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
287
288 if ucm == nil {
289 log.Warn().Msg("cannot inject scripts: user content manager is nil")
290 return
291 }
292
293 addScript := func(script *webkit.UserScript, label string) {
294 if script == nil {
295 log.Warn().Str("script", label).Msg("failed to create user script")
296 return
297 }
298 ucm.AddScript(script)
299 log.Debug().Str("script", label).Msg("injected user script")
300 }
301
302 prefersDark := ci.PrefersDark()
303
304 // 1. Inject WebRTC compatibility aliases for all pages.
305 addScript(
306 webkit.NewUserScript(
307 buildWebRTCCompatScript(),
308 webkit.UserContentInjectTopFrameValue,
309 webkit.UserScriptInjectAtDocumentStartValue,
310 nil,
311 nil,
312 ),
313 "webrtc-compat-shim",
314 )
315
316 // 2. Inject WebView ID for debugging (internal pages only)
317 if webviewID != 0 {
318 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
319 addScript(
320 webkit.NewUserScript(
321 idScript,
322 webkit.UserContentInjectTopFrameValue,
323 webkit.UserScriptInjectAtDocumentStartValue,
324 internalPageAllowList,
325 nil,
326 ),
327 "webview-id",
328 )
329 log.Debug().Uint64("webview_id", uint64(webviewID)).Msg("webview ID script injected")
330 } else {
331 log.Warn().Msg("webview ID is 0, skipping ID injection")
332 }
333
334 // 3. Inject dark mode handler for internal pages only
335 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
336 internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
337 addScript(
338 webkit.NewUserScript(
339 internalDarkModeScript,
340 webkit.UserContentInjectTopFrameValue,
341 webkit.UserScriptInjectAtDocumentStartValue,
342 internalPageAllowList,
343 nil,
344 ),
345 "internal-dark-mode-handler",
346 )
347
348 // 4. Inject theme CSS for internal pages (dumb://* only)
349 if ci.themeCSSVars != "" {
350 // Escape for JS string literal
351 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
352 escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
353 escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
354 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
355 addScript(
356 webkit.NewUserScript(
357 themeCSSInjectionScript,
358 webkit.UserContentInjectTopFrameValue,
359 webkit.UserScriptInjectAtDocumentEndValue,
360 internalPageAllowList,
361 nil,
362 ),
363 "theme-css-vars",
364 )
365 log.Debug().Msg("theme CSS vars injection configured for internal pages")
366 }
367
368 // 5. Inject find highlight CSS for all pages
369 if ci.findCSS != "" {
370 stylesheet := webkit.NewUserStyleSheet(
371 ci.findCSS,
372 webkit.UserContentInjectAllFramesValue,
373 webkit.UserStyleLevelUserValue,
374 nil,
375 nil,
376 )
377 if stylesheet == nil {
378 log.Warn().Msg("failed to create find highlight stylesheet")
379 } else {
380 ucm.AddStyleSheet(stylesheet)
381 log.Debug().Msg("find highlight stylesheet injected")
382 }
383 }
384
385 // 6. Inject auto-copy selection script for all pages (if enabled)
386 autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
387 if autoCopyEnabled {
388 addScript(
389 webkit.NewUserScript(
390 autoCopySelectionScript,
391 webkit.UserContentInjectTopFrameValue,
392 webkit.UserScriptInjectAtDocumentEndValue,
393 nil, // All pages
394 nil,
395 ),
396 "auto-copy-selection",
397 )
398 log.Debug().Msg("auto-copy selection script injected")
399 }
400
401 // 7. Inject accent key detection for all pages and all frames (unconditional).
402 // JS only reports keydown/keyup events; Go handles timing and picker display.
403 addScript(
404 webkit.NewUserScript(
405 accentDetectionScript,
406 accentDetectionInjectionMode,
407 webkit.UserScriptInjectAtDocumentEndValue,
408 nil, // all pages
409 nil,
410 ),
411 "accent-key-detection",
412 )
413
414 log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected")
415}
416
417// RefreshScripts clears and re-injects user scripts for a single WebView.
418// Implements port.ContentInjector.
419// Returns nil for "not applicable" cases (nil wv, wrong engine type, nil UCM).
420func (ci *ContentInjector) RefreshScripts(ctx context.Context, wv port.WebView) error {
421 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
422 if wv == nil {
423 log.Debug().Msg("RefreshScripts: nil webview")
424 return nil
425 }
426 wkWV, ok := wv.(*WebView)
427 if !ok {
428 log.Debug().Msg("RefreshScripts: webview is not *webkit.WebView")
429 return nil
430 }
431 ucm := wkWV.UserContentManager()
432 if ucm == nil {
433 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("RefreshScripts: nil UserContentManager")
434 return nil
435 }
436 ucm.RemoveAllScripts()
437 ucm.RemoveAllStyleSheets()
438 ci.InjectScripts(ctx, ucm, wv.ID())
439 return nil
440}