1package webkit
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/bnema/puregotk-webkit/webkit"
11)
12
13const (
14 // ScriptWorldName is the isolated world used for the injected UI.
15 ScriptWorldName = "dumber"
16 // MessageHandlerName is the name of the script message handler registered with WebKit.
17 MessageHandlerName = "dumber"
18)
19
20// themeCSSScript injects theme CSS into the page.
21// The %s placeholder is replaced with CSS text (newlines escaped as \n).
22const themeCSSScript = `(function() {
23 var style = document.createElement('style');
24 style.setAttribute('data-dumber-theme-vars', '');
25 style.textContent = '%s';
26 (document.head || document.documentElement).appendChild(style);
27})();`
28
29// internalDarkModeScriptTemplate is injected ONLY on internal pages (dumb://*).
30// It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia
31// for JS-based dark mode detection in WebUI components.
32//
33// NOTE: This script is NOT injected on external pages. External pages receive
34// dark mode preference via libadwaita's StyleManager, which WebKit respects
35// for the native prefers-color-scheme media query.
36//
37// The matchMedia patch handles various query formats:
38// - (prefers-color-scheme: dark)
39// - (prefers-color-scheme:dark) -- no space
40// - screen and (prefers-color-scheme: dark)
41const internalDarkModeScriptTemplate = `(function() {
42 var prefersDark = %t;
43 window.__dumber_gtk_prefers_dark = prefersDark;
44 var originalMatchMedia = window.matchMedia.bind(window);
45
46 // Apply dark/light class to document element for Tailwind CSS theming
47 if (prefersDark) {
48 document.documentElement.classList.add('dark');
49 document.documentElement.classList.remove('light');
50 } else {
51 document.documentElement.classList.add('light');
52 document.documentElement.classList.remove('dark');
53 }
54
55 // Helper: Check if query is a prefers-color-scheme query
56 function isColorSchemeQuery(query, scheme) {
57 if (typeof query !== 'string') return false;
58 var normalized = query.replace(/\s+/g, '').toLowerCase();
59 return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
60 }
61
62 // Create a fake MediaQueryList that implements the full interface
63 function createFakeMediaQueryList(query, matches) {
64 var listeners = [];
65 var onchangeHandler = null;
66
67 return {
68 matches: matches,
69 media: query,
70 get onchange() { return onchangeHandler; },
71 set onchange(fn) { onchangeHandler = fn; },
72 addListener: function(cb) {
73 if (typeof cb === 'function') listeners.push(cb);
74 },
75 removeListener: function(cb) {
76 var idx = listeners.indexOf(cb);
77 if (idx !== -1) listeners.splice(idx, 1);
78 },
79 addEventListener: function(type, cb) {
80 if (type === 'change' && typeof cb === 'function') {
81 listeners.push(cb);
82 }
83 },
84 removeEventListener: function(type, cb) {
85 if (type === 'change') {
86 var idx = listeners.indexOf(cb);
87 if (idx !== -1) listeners.splice(idx, 1);
88 }
89 },
90 dispatchEvent: function(event) {
91 for (var i = 0; i < listeners.length; i++) {
92 try { listeners[i](event); } catch (e) {}
93 }
94 if (onchangeHandler) {
95 try { onchangeHandler(event); } catch (e) {}
96 }
97 return true;
98 }
99 };
100 }
101
102 // Patch matchMedia for prefers-color-scheme queries
103 window.matchMedia = function(query) {
104 if (isColorSchemeQuery(query, 'dark')) {
105 return createFakeMediaQueryList(query, prefersDark);
106 }
107 if (isColorSchemeQuery(query, 'light')) {
108 return createFakeMediaQueryList(query, !prefersDark);
109 }
110 return originalMatchMedia(query);
111 };
112})();`
113
114// internalPageAllowList restricts script injection to internal dumb:// pages only.
115var internalPageAllowList = []string{"dumb://*"}
116
117// autoCopySelectionScript is injected to enable auto-copy on text selection (zellij-style).
118// It listens for selection changes, debounces them, and posts to the Go message handler.
119// Skips selections in input fields/textareas and requires minimum 2 characters.
120const autoCopySelectionScript = `(function() {
121 var debounceTimer = null;
122 var MIN_LENGTH = 2;
123
124 document.addEventListener('selectionchange', function() {
125 // Skip if selection is inside input/textarea/contenteditable
126 var activeEl = document.activeElement;
127 if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) {
128 return;
129 }
130
131 clearTimeout(debounceTimer);
132 debounceTimer = setTimeout(function() {
133 var text = window.getSelection().toString().trim();
134 if (text.length >= MIN_LENGTH && window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) {
135 window.webkit.messageHandlers.dumber.postMessage({
136 type: 'auto_copy_selection',
137 payload: { text: text }
138 });
139 }
140 }, 300);
141 });
142})();`
143
144// webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names.
145// Some pages gate support on window.RTCPeerConnection and report false negatives
146// when only webkit-prefixed constructors are present.
147const webRTCCompatScript = `(function() {
148 if (!window.RTCPeerConnection && window.webkitRTCPeerConnection) {
149 window.RTCPeerConnection = window.webkitRTCPeerConnection;
150 }
151 if (!window.RTCSessionDescription && window.webkitRTCSessionDescription) {
152 window.RTCSessionDescription = window.webkitRTCSessionDescription;
153 }
154 if (!window.RTCIceCandidate && window.webkitRTCIceCandidate) {
155 window.RTCIceCandidate = window.webkitRTCIceCandidate;
156 }
157})();`
158
159func buildWebRTCCompatScript() string {
160 return webRTCCompatScript
161}
162
163// ContentInjector encapsulates script injection into WebViews.
164// It injects dark mode detection scripts for internal pages (dumb://)
165// and theme CSS variables for WebUI styling.
166// External pages receive dark mode preference via libadwaita's StyleManager.
167// Implements port.ContentInjector interface.
168type ContentInjector struct {
169 colorResolver port.ColorSchemeResolver
170 themeCSSVars string // CSS custom property declarations for WebUI
171 findCSS string // CSS for find-in-page highlight styling
172 autoCopyConfigGetter func() bool // Dynamic getter for auto-copy config
173}
174
175// NewContentInjector creates a new injector instance.
176// The resolver is used to dynamically determine dark mode preference.
177func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector {
178 return &ContentInjector{
179 colorResolver: resolver,
180 }
181}
182
183// SetAutoCopyConfigGetter sets the function to dynamically check if auto-copy is enabled.
184// This is called during script injection to determine whether to inject the selection listener.
185func (ci *ContentInjector) SetAutoCopyConfigGetter(getter func() bool) {
186 ci.autoCopyConfigGetter = getter
187}
188
189// InjectThemeCSS stores CSS variables for injection into internal pages.
190// Implements port.ContentInjector interface.
191// The CSS will be injected when InjectScripts is called on WebView creation.
192func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error {
193 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
194 ci.themeCSSVars = css
195 log.Debug().Int("css_len", len(css)).Msg("theme CSS vars set for injection")
196 return nil
197}
198
199// InjectFindHighlightCSS stores CSS for find-in-page highlight styling.
200func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
201 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
202 ci.findCSS = css
203 log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set for injection")
204 return nil
205}
206
207// PrefersDark returns the current dark mode preference from the resolver.
208func (ci *ContentInjector) PrefersDark() bool {
209 return ci.colorResolver.Resolve().PrefersDark
210}
211
212// InjectScripts adds scripts to the given content manager.
213// For internal pages (dumb://*):
214// - Injects dark mode detection (class on <html>, matchMedia patch)
215// - Injects theme CSS variables for WebUI styling
216//
217// For external pages:
218// - Only injects find highlight CSS
219// - Dark mode is handled natively via libadwaita's StyleManager
220func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
221 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
222
223 if ucm == nil {
224 log.Warn().Msg("cannot inject scripts: user content manager is nil")
225 return
226 }
227
228 addScript := func(script *webkit.UserScript, label string) {
229 if script == nil {
230 log.Warn().Str("script", label).Msg("failed to create user script")
231 return
232 }
233 ucm.AddScript(script)
234 log.Debug().Str("script", label).Msg("injected user script")
235 }
236
237 prefersDark := ci.PrefersDark()
238
239 // 1. Inject WebRTC compatibility aliases for all pages.
240 addScript(
241 webkit.NewUserScript(
242 buildWebRTCCompatScript(),
243 webkit.UserContentInjectTopFrameValue,
244 webkit.UserScriptInjectAtDocumentStartValue,
245 nil,
246 nil,
247 ),
248 "webrtc-compat-shim",
249 )
250
251 // 2. Inject WebView ID for debugging (internal pages only)
252 if webviewID != 0 {
253 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
254 addScript(
255 webkit.NewUserScript(
256 idScript,
257 webkit.UserContentInjectTopFrameValue,
258 webkit.UserScriptInjectAtDocumentStartValue,
259 internalPageAllowList,
260 nil,
261 ),
262 "webview-id",
263 )
264 log.Debug().Uint64("webview_id", uint64(webviewID)).Msg("webview ID script injected")
265 } else {
266 log.Warn().Msg("webview ID is 0, skipping ID injection")
267 }
268
269 // 3. Inject dark mode handler for internal pages only
270 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
271 internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
272 addScript(
273 webkit.NewUserScript(
274 internalDarkModeScript,
275 webkit.UserContentInjectTopFrameValue,
276 webkit.UserScriptInjectAtDocumentStartValue,
277 internalPageAllowList,
278 nil,
279 ),
280 "internal-dark-mode-handler",
281 )
282
283 // 4. Inject theme CSS for internal pages (dumb://* only)
284 if ci.themeCSSVars != "" {
285 // Escape for JS string literal
286 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
287 escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
288 escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
289 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
290 addScript(
291 webkit.NewUserScript(
292 themeCSSInjectionScript,
293 webkit.UserContentInjectTopFrameValue,
294 webkit.UserScriptInjectAtDocumentEndValue,
295 internalPageAllowList,
296 nil,
297 ),
298 "theme-css-vars",
299 )
300 log.Debug().Msg("theme CSS vars injection configured for internal pages")
301 }
302
303 // 5. Inject find highlight CSS for all pages
304 if ci.findCSS != "" {
305 stylesheet := webkit.NewUserStyleSheet(
306 ci.findCSS,
307 webkit.UserContentInjectAllFramesValue,
308 webkit.UserStyleLevelUserValue,
309 nil,
310 nil,
311 )
312 if stylesheet == nil {
313 log.Warn().Msg("failed to create find highlight stylesheet")
314 } else {
315 ucm.AddStyleSheet(stylesheet)
316 log.Debug().Msg("find highlight stylesheet injected")
317 }
318 }
319
320 // 6. Inject auto-copy selection script for all pages (if enabled)
321 autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
322 if autoCopyEnabled {
323 addScript(
324 webkit.NewUserScript(
325 autoCopySelectionScript,
326 webkit.UserContentInjectTopFrameValue,
327 webkit.UserScriptInjectAtDocumentEndValue,
328 nil, // All pages
329 nil,
330 ),
331 "auto-copy-selection",
332 )
333 log.Debug().Msg("auto-copy selection script injected")
334 }
335
336 log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected")
337}