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// ContentInjector encapsulates script injection into WebViews.
145// It injects dark mode detection scripts for internal pages (dumb://)
146// and theme CSS variables for WebUI styling.
147// External pages receive dark mode preference via libadwaita's StyleManager.
148// Implements port.ContentInjector interface.
149type ContentInjector struct {
150 colorResolver port.ColorSchemeResolver
151 themeCSSVars string // CSS custom property declarations for WebUI
152 findCSS string // CSS for find-in-page highlight styling
153 autoCopyConfigGetter func() bool // Dynamic getter for auto-copy config
154}
155
156// NewContentInjector creates a new injector instance.
157// The resolver is used to dynamically determine dark mode preference.
158func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector {
159 return &ContentInjector{
160 colorResolver: resolver,
161 }
162}
163
164// SetAutoCopyConfigGetter sets the function to dynamically check if auto-copy is enabled.
165// This is called during script injection to determine whether to inject the selection listener.
166func (ci *ContentInjector) SetAutoCopyConfigGetter(getter func() bool) {
167 ci.autoCopyConfigGetter = getter
168}
169
170// InjectThemeCSS stores CSS variables for injection into internal pages.
171// Implements port.ContentInjector interface.
172// The CSS will be injected when InjectScripts is called on WebView creation.
173func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error {
174 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
175 ci.themeCSSVars = css
176 log.Debug().Int("css_len", len(css)).Msg("theme CSS vars set for injection")
177 return nil
178}
179
180// InjectFindHighlightCSS stores CSS for find-in-page highlight styling.
181func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
182 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
183 ci.findCSS = css
184 log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set for injection")
185 return nil
186}
187
188// PrefersDark returns the current dark mode preference from the resolver.
189func (ci *ContentInjector) PrefersDark() bool {
190 return ci.colorResolver.Resolve().PrefersDark
191}
192
193// InjectScripts adds scripts to the given content manager.
194// For internal pages (dumb://*):
195// - Injects dark mode detection (class on <html>, matchMedia patch)
196// - Injects theme CSS variables for WebUI styling
197//
198// For external pages:
199// - Only injects find highlight CSS
200// - Dark mode is handled natively via libadwaita's StyleManager
201func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
202 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
203
204 if ucm == nil {
205 log.Warn().Msg("cannot inject scripts: user content manager is nil")
206 return
207 }
208
209 addScript := func(script *webkit.UserScript, label string) {
210 if script == nil {
211 log.Warn().Str("script", label).Msg("failed to create user script")
212 return
213 }
214 ucm.AddScript(script)
215 log.Debug().Str("script", label).Msg("injected user script")
216 }
217
218 prefersDark := ci.PrefersDark()
219
220 // 1. Inject WebView ID for debugging (internal pages only)
221 if webviewID != 0 {
222 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
223 addScript(
224 webkit.NewUserScript(
225 idScript,
226 webkit.UserContentInjectTopFrameValue,
227 webkit.UserScriptInjectAtDocumentStartValue,
228 internalPageAllowList,
229 nil,
230 ),
231 "webview-id",
232 )
233 log.Debug().Uint64("webview_id", uint64(webviewID)).Msg("webview ID script injected")
234 } else {
235 log.Warn().Msg("webview ID is 0, skipping ID injection")
236 }
237
238 // 2. Inject dark mode handler for internal pages only
239 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
240 internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
241 addScript(
242 webkit.NewUserScript(
243 internalDarkModeScript,
244 webkit.UserContentInjectTopFrameValue,
245 webkit.UserScriptInjectAtDocumentStartValue,
246 internalPageAllowList,
247 nil,
248 ),
249 "internal-dark-mode-handler",
250 )
251
252 // 3. Inject theme CSS for internal pages (dumb://* only)
253 if ci.themeCSSVars != "" {
254 // Escape for JS string literal
255 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
256 escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
257 escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
258 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
259 addScript(
260 webkit.NewUserScript(
261 themeCSSInjectionScript,
262 webkit.UserContentInjectTopFrameValue,
263 webkit.UserScriptInjectAtDocumentEndValue,
264 internalPageAllowList,
265 nil,
266 ),
267 "theme-css-vars",
268 )
269 log.Debug().Msg("theme CSS vars injection configured for internal pages")
270 }
271
272 // 4. Inject find highlight CSS for all pages
273 if ci.findCSS != "" {
274 stylesheet := webkit.NewUserStyleSheet(
275 ci.findCSS,
276 webkit.UserContentInjectAllFramesValue,
277 webkit.UserStyleLevelUserValue,
278 nil,
279 nil,
280 )
281 if stylesheet == nil {
282 log.Warn().Msg("failed to create find highlight stylesheet")
283 } else {
284 ucm.AddStyleSheet(stylesheet)
285 log.Debug().Msg("find highlight stylesheet injected")
286 }
287 }
288
289 // 5. Inject auto-copy selection script for all pages (if enabled)
290 autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
291 if autoCopyEnabled {
292 addScript(
293 webkit.NewUserScript(
294 autoCopySelectionScript,
295 webkit.UserContentInjectTopFrameValue,
296 webkit.UserScriptInjectAtDocumentEndValue,
297 nil, // All pages
298 nil,
299 ),
300 "auto-copy-selection",
301 )
302 log.Debug().Msg("auto-copy selection script injected")
303 }
304
305 log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected")
306}