1package webkit
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "github.com/bnema/dumber/internal/logging"
9 "github.com/bnema/puregotk-webkit/webkit"
10)
11
12const (
13 // ScriptWorldName is the isolated world used for the injected UI.
14 ScriptWorldName = "dumber"
15 // MessageHandlerName is the name of the script message handler registered with WebKit.
16 MessageHandlerName = "dumber"
17)
18
19// themeCSSScript injects theme CSS into the page.
20// The %s placeholder is replaced with CSS text (newlines escaped as \n).
21const themeCSSScript = `(function() {
22 var style = document.createElement('style');
23 style.setAttribute('data-dumber-theme-vars', '');
24 style.textContent = '%s';
25 (document.head || document.documentElement).appendChild(style);
26})();`
27
28// earlyBackgroundScript injects a temporary background color to prevent white flash.
29// It's injected at document start and removed after the page loads.
30// The %s placeholder is replaced with the hex color (e.g., "#0a0a0b").
31const earlyBackgroundScript = `(function() {
32 var style = document.createElement('style');
33 style.id = '__dumber_early_bg';
34 style.textContent = 'html,body{background-color:%s!important}';
35 document.documentElement.appendChild(style);
36
37 // Remove the style after page content loads to avoid overriding page styles
38 function removeEarlyBg() {
39 var s = document.getElementById('__dumber_early_bg');
40 if (s) s.remove();
41 }
42
43 // Try multiple events to ensure removal
44 if (document.readyState === 'complete') {
45 setTimeout(removeEarlyBg, 100);
46 } else {
47 window.addEventListener('DOMContentLoaded', function() {
48 setTimeout(removeEarlyBg, 100);
49 }, {once: true});
50 window.addEventListener('load', function() {
51 setTimeout(removeEarlyBg, 100);
52 }, {once: true});
53 }
54})();`
55
56// darkModeScript patches window.matchMedia and sets theme class on <html>.
57// It must be injected at document start, after __dumber_gtk_prefers_dark is set.
58// This script:
59// 1. Sets dark/light class on <html> for CSS-based theme detection
60// 2. Injects color-scheme meta tag and CSS for browser UA style hints
61// 3. Patches matchMedia to return the GTK theme preference for prefers-color-scheme queries
62//
63// The matchMedia patch handles various query formats:
64// - (prefers-color-scheme: dark)
65// - (prefers-color-scheme:dark) -- no space
66// - screen and (prefers-color-scheme: dark)
67// And provides proper addEventListener/removeEventListener stubs for compatibility.
68const darkModeScript = `(function() {
69 var prefersDark = window.__dumber_gtk_prefers_dark || false;
70 var originalMatchMedia = window.matchMedia.bind(window);
71
72 // Apply dark/light class to document element for CSS theming
73 if (prefersDark) {
74 document.documentElement.classList.add('dark');
75 document.documentElement.classList.remove('light');
76 } else {
77 document.documentElement.classList.add('light');
78 document.documentElement.classList.remove('dark');
79 }
80
81 // Inject color-scheme meta tag
82 var meta = document.createElement('meta');
83 meta.name = 'color-scheme';
84 meta.content = prefersDark ? 'dark light' : 'light dark';
85 document.documentElement.appendChild(meta);
86
87 // Inject root color-scheme style
88 var style = document.createElement('style');
89 style.setAttribute('data-dumber-color-scheme', '');
90 style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}';
91 document.documentElement.appendChild(style);
92
93 // Helper: Check if query is a prefers-color-scheme query
94 // Normalizes by removing whitespace and lowercasing
95 function isColorSchemeQuery(query, scheme) {
96 if (typeof query !== 'string') return false;
97 var normalized = query.replace(/\s+/g, '').toLowerCase();
98 return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
99 }
100
101 // Create a fake MediaQueryList that implements the full interface
102 function createFakeMediaQueryList(query, matches) {
103 var listeners = [];
104 var onchangeHandler = null;
105
106 return {
107 matches: matches,
108 media: query,
109 get onchange() { return onchangeHandler; },
110 set onchange(fn) { onchangeHandler = fn; },
111 // Deprecated but still used by some sites
112 addListener: function(cb) {
113 if (typeof cb === 'function') listeners.push(cb);
114 },
115 removeListener: function(cb) {
116 var idx = listeners.indexOf(cb);
117 if (idx !== -1) listeners.splice(idx, 1);
118 },
119 // Modern API
120 addEventListener: function(type, cb) {
121 if (type === 'change' && typeof cb === 'function') {
122 listeners.push(cb);
123 }
124 },
125 removeEventListener: function(type, cb) {
126 if (type === 'change') {
127 var idx = listeners.indexOf(cb);
128 if (idx !== -1) listeners.splice(idx, 1);
129 }
130 },
131 dispatchEvent: function(event) {
132 for (var i = 0; i < listeners.length; i++) {
133 try { listeners[i](event); } catch (e) {}
134 }
135 if (onchangeHandler) {
136 try { onchangeHandler(event); } catch (e) {}
137 }
138 return true;
139 }
140 };
141 }
142
143 // Patch matchMedia for prefers-color-scheme queries
144 window.matchMedia = function(query) {
145 if (isColorSchemeQuery(query, 'dark')) {
146 return createFakeMediaQueryList(query, prefersDark);
147 }
148 if (isColorSchemeQuery(query, 'light')) {
149 return createFakeMediaQueryList(query, !prefersDark);
150 }
151 return originalMatchMedia(query);
152 };
153})();`
154
155// ContentInjector encapsulates script injection into WebViews.
156// It injects minimal scripts for dark mode detection in web pages
157// and theme CSS variables for internal pages (dumb://).
158// Implements port.ContentInjector interface.
159type ContentInjector struct {
160 prefersDark bool
161 themeCSSVars string // CSS custom property declarations for WebUI
162 findCSS string // CSS for find-in-page highlight styling
163 bgColor string // Background color hex for early injection (prevents white flash)
164}
165
166// NewContentInjector creates a new injector instance.
167// The prefersDark parameter should come from ThemeManager.PrefersDark().
168func NewContentInjector(prefersDark bool) *ContentInjector {
169 return &ContentInjector{
170 prefersDark: prefersDark,
171 }
172}
173
174// InjectThemeCSS stores CSS variables for injection into internal pages.
175// Implements port.ContentInjector interface.
176// The CSS will be injected when InjectScripts is called on WebView creation.
177func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error {
178 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
179 ci.themeCSSVars = css
180 log.Debug().Int("css_len", len(css)).Msg("theme CSS vars set for injection")
181 return nil
182}
183
184// InjectFindHighlightCSS stores CSS for find-in-page highlight styling.
185func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
186 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
187 ci.findCSS = css
188 log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set for injection")
189 return nil
190}
191
192// PrefersDark returns the current dark mode preference.
193func (ci *ContentInjector) PrefersDark() bool {
194 return ci.prefersDark
195}
196
197// SetPrefersDark updates the dark mode preference.
198// Call this when theme changes at runtime.
199func (ci *ContentInjector) SetPrefersDark(prefersDark bool) {
200 ci.prefersDark = prefersDark
201}
202
203// SetBackgroundColor sets the background color for early CSS injection.
204// This color is applied to html/body at document start to prevent white flash.
205// The hex color should include the # prefix (e.g., "#0a0a0b").
206func (ci *ContentInjector) SetBackgroundColor(hex string) {
207 ci.bgColor = hex
208}
209
210// InjectScripts adds the minimal dark mode detection scripts to the given content manager.
211// Only injects:
212// - window.__dumber_gtk_prefers_dark flag
213// - window.__dumber_webview_id (for debugging)
214// - darkModeScript (patches matchMedia for prefers-color-scheme)
215func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
216 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
217
218 if ucm == nil {
219 log.Warn().Msg("cannot inject scripts: user content manager is nil")
220 return
221 }
222
223 addScript := func(script *webkit.UserScript, label string) {
224 if script == nil {
225 log.Warn().Str("script", label).Msg("failed to create user script")
226 return
227 }
228 ucm.AddScript(script)
229 log.Debug().Str("script", label).Msg("injected user script")
230 }
231
232 // 1. Inject early background color to prevent white flash (must be first!)
233 if ci.bgColor != "" {
234 earlyBgScript := fmt.Sprintf(earlyBackgroundScript, ci.bgColor)
235 addScript(
236 webkit.NewUserScript(
237 earlyBgScript,
238 webkit.UserContentInjectTopFrameValue,
239 webkit.UserScriptInjectAtDocumentStartValue,
240 nil,
241 nil,
242 ),
243 "early-background",
244 )
245 }
246
247 // 2. Inject GTK dark mode preference (must be before dark mode handler)
248 darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)
249 addScript(
250 webkit.NewUserScript(
251 darkModePrefScript,
252 webkit.UserContentInjectTopFrameValue,
253 webkit.UserScriptInjectAtDocumentStartValue,
254 nil,
255 nil,
256 ),
257 "gtk-dark-mode-pref",
258 )
259
260 // 3. Inject WebView ID for debugging
261 if webviewID != 0 {
262 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
263 addScript(
264 webkit.NewUserScript(
265 idScript,
266 webkit.UserContentInjectTopFrameValue,
267 webkit.UserScriptInjectAtDocumentStartValue,
268 nil,
269 nil,
270 ),
271 "webview-id",
272 )
273 }
274
275 // 4. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark)
276 addScript(
277 webkit.NewUserScript(
278 darkModeScript,
279 webkit.UserContentInjectTopFrameValue,
280 webkit.UserScriptInjectAtDocumentStartValue,
281 nil,
282 nil,
283 ),
284 "dark-mode-handler",
285 )
286
287 // 5. Inject theme CSS for internal pages (dumb://* only)
288 if ci.themeCSSVars != "" {
289 // Escape for JS string literal
290 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
291 escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
292 escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
293 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
294 addScript(
295 webkit.NewUserScript(
296 themeCSSInjectionScript,
297 webkit.UserContentInjectTopFrameValue,
298 webkit.UserScriptInjectAtDocumentEndValue,
299 nil, // Inject on all pages - allowlist patterns don't work for custom schemes
300 nil,
301 ),
302 "theme-css-vars",
303 )
304 log.Debug().Msg("theme CSS vars injection configured")
305 }
306
307 // 6. Inject find highlight CSS for all pages
308 if ci.findCSS != "" {
309 stylesheet := webkit.NewUserStyleSheet(
310 ci.findCSS,
311 webkit.UserContentInjectAllFramesValue,
312 webkit.UserStyleLevelUserValue,
313 nil,
314 nil,
315 )
316 if stylesheet == nil {
317 log.Warn().Msg("failed to create find highlight stylesheet")
318 } else {
319 ucm.AddStyleSheet(stylesheet)
320 log.Debug().Msg("find highlight stylesheet injected")
321 }
322 }
323
324 log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected")
325}