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// ContentInjector encapsulates script injection into WebViews.
118// It injects dark mode detection scripts for internal pages (dumb://)
119// and theme CSS variables for WebUI styling.
120// External pages receive dark mode preference via libadwaita's StyleManager.
121// Implements port.ContentInjector interface.
122type ContentInjector struct {
123 colorResolver port.ColorSchemeResolver
124 themeCSSVars string // CSS custom property declarations for WebUI
125 findCSS string // CSS for find-in-page highlight styling
126}
127
128// NewContentInjector creates a new injector instance.
129// The resolver is used to dynamically determine dark mode preference.
130func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector {
131 return &ContentInjector{
132 colorResolver: resolver,
133 }
134}
135
136// InjectThemeCSS stores CSS variables for injection into internal pages.
137// Implements port.ContentInjector interface.
138// The CSS will be injected when InjectScripts is called on WebView creation.
139func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error {
140 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
141 ci.themeCSSVars = css
142 log.Debug().Int("css_len", len(css)).Msg("theme CSS vars set for injection")
143 return nil
144}
145
146// InjectFindHighlightCSS stores CSS for find-in-page highlight styling.
147func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
148 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
149 ci.findCSS = css
150 log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set for injection")
151 return nil
152}
153
154// PrefersDark returns the current dark mode preference from the resolver.
155func (ci *ContentInjector) PrefersDark() bool {
156 return ci.colorResolver.Resolve().PrefersDark
157}
158
159// InjectScripts adds scripts to the given content manager.
160// For internal pages (dumb://*):
161// - Injects dark mode detection (class on <html>, matchMedia patch)
162// - Injects theme CSS variables for WebUI styling
163//
164// For external pages:
165// - Only injects find highlight CSS
166// - Dark mode is handled natively via libadwaita's StyleManager
167func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
168 log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
169
170 if ucm == nil {
171 log.Warn().Msg("cannot inject scripts: user content manager is nil")
172 return
173 }
174
175 addScript := func(script *webkit.UserScript, label string) {
176 if script == nil {
177 log.Warn().Str("script", label).Msg("failed to create user script")
178 return
179 }
180 ucm.AddScript(script)
181 log.Debug().Str("script", label).Msg("injected user script")
182 }
183
184 prefersDark := ci.PrefersDark()
185
186 // 1. Inject WebView ID for debugging (internal pages only)
187 if webviewID != 0 {
188 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
189 addScript(
190 webkit.NewUserScript(
191 idScript,
192 webkit.UserContentInjectTopFrameValue,
193 webkit.UserScriptInjectAtDocumentStartValue,
194 internalPageAllowList,
195 nil,
196 ),
197 "webview-id",
198 )
199 log.Debug().Uint64("webview_id", uint64(webviewID)).Msg("webview ID script injected")
200 } else {
201 log.Warn().Msg("webview ID is 0, skipping ID injection")
202 }
203
204 // 2. Inject dark mode handler for internal pages only
205 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
206 internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
207 addScript(
208 webkit.NewUserScript(
209 internalDarkModeScript,
210 webkit.UserContentInjectTopFrameValue,
211 webkit.UserScriptInjectAtDocumentStartValue,
212 internalPageAllowList,
213 nil,
214 ),
215 "internal-dark-mode-handler",
216 )
217
218 // 3. Inject theme CSS for internal pages (dumb://* only)
219 if ci.themeCSSVars != "" {
220 // Escape for JS string literal
221 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
222 escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
223 escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
224 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
225 addScript(
226 webkit.NewUserScript(
227 themeCSSInjectionScript,
228 webkit.UserContentInjectTopFrameValue,
229 webkit.UserScriptInjectAtDocumentEndValue,
230 internalPageAllowList,
231 nil,
232 ),
233 "theme-css-vars",
234 )
235 log.Debug().Msg("theme CSS vars injection configured for internal pages")
236 }
237
238 // 4. Inject find highlight CSS for all pages
239 if ci.findCSS != "" {
240 stylesheet := webkit.NewUserStyleSheet(
241 ci.findCSS,
242 webkit.UserContentInjectAllFramesValue,
243 webkit.UserStyleLevelUserValue,
244 nil,
245 nil,
246 )
247 if stylesheet == nil {
248 log.Warn().Msg("failed to create find highlight stylesheet")
249 } else {
250 ucm.AddStyleSheet(stylesheet)
251 log.Debug().Msg("find highlight stylesheet injected")
252 }
253 }
254
255 log.Debug().Bool("prefers_dark", prefersDark).Msg("scripts injected")
256}