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