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