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