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