fix(webkit): remove color-scheme injection to prevent form artifacts on external sites
- commit
- e7d5affdbb5d5a1798dc08e8b56017345f062e16
- author
- bnema · · 2025-12-29 18:38 UTC
- parent
- 4c18f79
- signature
- GPG Unverified
2B82207D370127A0The key is unknown
3 files · +68 -108
M internal/bootstrap/webkit_stack.go +0 -3
| @@ -148,9 +148,6 @@ func BuildWebKitStack( | |||
| 148 | 148 | settings := webkit.NewSettingsManager(ctx, cfg) | |
| 149 | 149 | injector := webkit.NewContentInjector(themeManager.PrefersDark()) | |
| 150 | 150 | ||
| 151 | - | // Set background color on injector for early CSS injection (prevents white flash) | |
| 152 | - | injector.SetBackgroundColor(themeManager.GetCurrentPalette().Background) | |
| 153 | - | ||
| 154 | 151 | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector) | |
| 155 | 152 | themeCSSText := themeManager.GetWebUIThemeCSS() | |
| 156 | 153 | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil { | |
| @@ -148,9 +148,6 @@ func BuildWebKitStack( | |||||
| 148 | settings := webkit.NewSettingsManager(ctx, cfg) | 148 | settings := webkit.NewSettingsManager(ctx, cfg) | ||
| 149 | injector := webkit.NewContentInjector(themeManager.PrefersDark()) | 149 | injector := webkit.NewContentInjector(themeManager.PrefersDark()) | ||
| 150 | 150 | ||||
| 151 | - | // Set background color on injector for early CSS injection (prevents white flash) | |||
| 152 | - | injector.SetBackgroundColor(themeManager.GetCurrentPalette().Background) | |||
| 153 | - | ||||
| 154 | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector) | 151 | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector) | ||
| 155 | themeCSSText := themeManager.GetWebUIThemeCSS() | 152 | themeCSSText := themeManager.GetWebUIThemeCSS() | ||
| 156 | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil { | 153 | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil { | ||
M internal/infrastructure/webkit/injector.go +37 -95
| @@ -25,51 +25,23 @@ const themeCSSScript = `(function() { | |||
| 25 | 25 | (document.head || document.documentElement).appendChild(style); | |
| 26 | 26 | })();` | |
| 27 | 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"). | |
| 31 | - | const 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 | |
| 28 | + | // internalDarkModeScript is injected ONLY on internal pages (dumb://*). | |
| 29 | + | // It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia | |
| 30 | + | // for JS-based dark mode detection in WebUI components. | |
| 31 | + | // | |
| 32 | + | // NOTE: This script is NOT injected on external pages. External pages receive | |
| 33 | + | // dark mode preference via libadwaita's StyleManager, which WebKit respects | |
| 34 | + | // for the native prefers-color-scheme media query. | |
| 62 | 35 | // | |
| 63 | 36 | // The matchMedia patch handles various query formats: | |
| 64 | 37 | // - (prefers-color-scheme: dark) | |
| 65 | 38 | // - (prefers-color-scheme:dark) -- no space | |
| 66 | 39 | // - screen and (prefers-color-scheme: dark) | |
| 67 | - | // And provides proper addEventListener/removeEventListener stubs for compatibility. | |
| 68 | - | const darkModeScript = `(function() { | |
| 40 | + | const internalDarkModeScript = `(function() { | |
| 69 | 41 | var prefersDark = window.__dumber_gtk_prefers_dark || false; | |
| 70 | 42 | var originalMatchMedia = window.matchMedia.bind(window); | |
| 71 | 43 | ||
| 72 | - | // Apply dark/light class to document element for CSS theming | |
| 44 | + | // Apply dark/light class to document element for Tailwind CSS theming | |
| 73 | 45 | if (prefersDark) { | |
| 74 | 46 | document.documentElement.classList.add('dark'); | |
| 75 | 47 | document.documentElement.classList.remove('light'); | |
| @@ -78,20 +50,7 @@ const darkModeScript = `(function() { | |||
| 78 | 50 | document.documentElement.classList.remove('dark'); | |
| 79 | 51 | } | |
| 80 | 52 | ||
| 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 | 53 | // Helper: Check if query is a prefers-color-scheme query | |
| 94 | - | // Normalizes by removing whitespace and lowercasing | |
| 95 | 54 | function isColorSchemeQuery(query, scheme) { | |
| 96 | 55 | if (typeof query !== 'string') return false; | |
| 97 | 56 | var normalized = query.replace(/\s+/g, '').toLowerCase(); | |
| @@ -108,7 +67,6 @@ const darkModeScript = `(function() { | |||
| 108 | 67 | media: query, | |
| 109 | 68 | get onchange() { return onchangeHandler; }, | |
| 110 | 69 | set onchange(fn) { onchangeHandler = fn; }, | |
| 111 | - | // Deprecated but still used by some sites | |
| 112 | 70 | addListener: function(cb) { | |
| 113 | 71 | if (typeof cb === 'function') listeners.push(cb); | |
| 114 | 72 | }, | |
| @@ -116,7 +74,6 @@ const darkModeScript = `(function() { | |||
| 116 | 74 | var idx = listeners.indexOf(cb); | |
| 117 | 75 | if (idx !== -1) listeners.splice(idx, 1); | |
| 118 | 76 | }, | |
| 119 | - | // Modern API | |
| 120 | 77 | addEventListener: function(type, cb) { | |
| 121 | 78 | if (type === 'change' && typeof cb === 'function') { | |
| 122 | 79 | listeners.push(cb); | |
| @@ -152,15 +109,18 @@ const darkModeScript = `(function() { | |||
| 152 | 109 | }; | |
| 153 | 110 | })();` | |
| 154 | 111 | ||
| 112 | + | // internalPageAllowList restricts script injection to internal dumb:// pages only. | |
| 113 | + | var internalPageAllowList = []string{"dumb://*"} | |
| 114 | + | ||
| 155 | 115 | // 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://). | |
| 116 | + | // It injects dark mode detection scripts for internal pages (dumb://) | |
| 117 | + | // and theme CSS variables for WebUI styling. | |
| 118 | + | // External pages receive dark mode preference via libadwaita's StyleManager. | |
| 158 | 119 | // Implements port.ContentInjector interface. | |
| 159 | 120 | type ContentInjector struct { | |
| 160 | 121 | prefersDark bool | |
| 161 | 122 | themeCSSVars string // CSS custom property declarations for WebUI | |
| 162 | 123 | findCSS string // CSS for find-in-page highlight styling | |
| 163 | - | bgColor string // Background color hex for early injection (prevents white flash) | |
| 164 | 124 | } | |
| 165 | 125 | ||
| 166 | 126 | // NewContentInjector creates a new injector instance. | |
| @@ -200,18 +160,14 @@ func (ci *ContentInjector) SetPrefersDark(prefersDark bool) { | |||
| 200 | 160 | ci.prefersDark = prefersDark | |
| 201 | 161 | } | |
| 202 | 162 | ||
| 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"). | |
| 206 | - | func (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) | |
| 163 | + | // InjectScripts adds scripts to the given content manager. | |
| 164 | + | // For internal pages (dumb://*): | |
| 165 | + | // - Injects dark mode detection (class on <html>, matchMedia patch) | |
| 166 | + | // - Injects theme CSS variables for WebUI styling | |
| 167 | + | // | |
| 168 | + | // For external pages: | |
| 169 | + | // - Only injects find highlight CSS | |
| 170 | + | // - Dark mode is handled natively via libadwaita's StyleManager | |
| 215 | 171 | func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) { | |
| 216 | 172 | log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger() | |
| 217 | 173 | ||
| @@ -229,35 +185,20 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 229 | 185 | log.Debug().Str("script", label).Msg("injected user script") | |
| 230 | 186 | } | |
| 231 | 187 | ||
| 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) | |
| 188 | + | // 1. Inject GTK dark mode preference for internal pages only | |
| 248 | 189 | darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark) | |
| 249 | 190 | addScript( | |
| 250 | 191 | webkit.NewUserScript( | |
| 251 | 192 | darkModePrefScript, | |
| 252 | 193 | webkit.UserContentInjectTopFrameValue, | |
| 253 | 194 | webkit.UserScriptInjectAtDocumentStartValue, | |
| 254 | - | nil, | |
| 195 | + | internalPageAllowList, | |
| 255 | 196 | nil, | |
| 256 | 197 | ), | |
| 257 | 198 | "gtk-dark-mode-pref", | |
| 258 | 199 | ) | |
| 259 | 200 | ||
| 260 | - | // 3. Inject WebView ID for debugging | |
| 201 | + | // 2. Inject WebView ID for debugging (internal pages only) | |
| 261 | 202 | if webviewID != 0 { | |
| 262 | 203 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | |
| 263 | 204 | addScript( | |
| @@ -265,26 +206,27 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 265 | 206 | idScript, | |
| 266 | 207 | webkit.UserContentInjectTopFrameValue, | |
| 267 | 208 | webkit.UserScriptInjectAtDocumentStartValue, | |
| 268 | - | nil, | |
| 209 | + | internalPageAllowList, | |
| 269 | 210 | nil, | |
| 270 | 211 | ), | |
| 271 | 212 | "webview-id", | |
| 272 | 213 | ) | |
| 273 | 214 | } | |
| 274 | 215 | ||
| 275 | - | // 4. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark) | |
| 216 | + | // 3. Inject dark mode handler for internal pages only | |
| 217 | + | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | |
| 276 | 218 | addScript( | |
| 277 | 219 | webkit.NewUserScript( | |
| 278 | - | darkModeScript, | |
| 220 | + | internalDarkModeScript, | |
| 279 | 221 | webkit.UserContentInjectTopFrameValue, | |
| 280 | 222 | webkit.UserScriptInjectAtDocumentStartValue, | |
| 281 | - | nil, | |
| 223 | + | internalPageAllowList, | |
| 282 | 224 | nil, | |
| 283 | 225 | ), | |
| 284 | - | "dark-mode-handler", | |
| 226 | + | "internal-dark-mode-handler", | |
| 285 | 227 | ) | |
| 286 | 228 | ||
| 287 | - | // 5. Inject theme CSS for internal pages (dumb://* only) | |
| 229 | + | // 4. Inject theme CSS for internal pages (dumb://* only) | |
| 288 | 230 | if ci.themeCSSVars != "" { | |
| 289 | 231 | // Escape for JS string literal | |
| 290 | 232 | escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\") | |
| @@ -296,15 +238,15 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 296 | 238 | themeCSSInjectionScript, | |
| 297 | 239 | webkit.UserContentInjectTopFrameValue, | |
| 298 | 240 | webkit.UserScriptInjectAtDocumentEndValue, | |
| 299 | - | nil, // Inject on all pages - allowlist patterns don't work for custom schemes | |
| 241 | + | internalPageAllowList, | |
| 300 | 242 | nil, | |
| 301 | 243 | ), | |
| 302 | 244 | "theme-css-vars", | |
| 303 | 245 | ) | |
| 304 | - | log.Debug().Msg("theme CSS vars injection configured") | |
| 246 | + | log.Debug().Msg("theme CSS vars injection configured for internal pages") | |
| 305 | 247 | } | |
| 306 | 248 | ||
| 307 | - | // 6. Inject find highlight CSS for all pages | |
| 249 | + | // 5. Inject find highlight CSS for all pages | |
| 308 | 250 | if ci.findCSS != "" { | |
| 309 | 251 | stylesheet := webkit.NewUserStyleSheet( | |
| 310 | 252 | ci.findCSS, | |
| @@ -321,5 +263,5 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 321 | 263 | } | |
| 322 | 264 | } | |
| 323 | 265 | ||
| 324 | - | log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected") | |
| 266 | + | log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("scripts injected") | |
| 325 | 267 | } | |
| @@ -25,51 +25,23 @@ const themeCSSScript = `(function() { | |||||
| 25 | (document.head || document.documentElement).appendChild(style); | 25 | (document.head || document.documentElement).appendChild(style); | ||
| 26 | })();` | 26 | })();` | ||
| 27 | 27 | ||||
| 28 | - | // earlyBackgroundScript injects a temporary background color to prevent white flash. | 28 | + | // internalDarkModeScript is injected ONLY on internal pages (dumb://*). |
| 29 | - | // It's injected at document start and removed after the page loads. | 29 | + | // It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia |
| 30 | - | // The %s placeholder is replaced with the hex color (e.g., "#0a0a0b"). | 30 | + | // for JS-based dark mode detection in WebUI components. |
| 31 | - | const earlyBackgroundScript = `(function() { | 31 | + | // |
| 32 | - | var style = document.createElement('style'); | 32 | + | // NOTE: This script is NOT injected on external pages. External pages receive |
| 33 | - | style.id = '__dumber_early_bg'; | 33 | + | // dark mode preference via libadwaita's StyleManager, which WebKit respects |
| 34 | - | style.textContent = 'html,body{background-color:%s!important}'; | 34 | + | // for the native prefers-color-scheme media query. |
| 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 | // | 35 | // | ||
| 63 | // The matchMedia patch handles various query formats: | 36 | // The matchMedia patch handles various query formats: | ||
| 64 | // - (prefers-color-scheme: dark) | 37 | // - (prefers-color-scheme: dark) | ||
| 65 | // - (prefers-color-scheme:dark) -- no space | 38 | // - (prefers-color-scheme:dark) -- no space | ||
| 66 | // - screen and (prefers-color-scheme: dark) | 39 | // - screen and (prefers-color-scheme: dark) | ||
| 67 | - | // And provides proper addEventListener/removeEventListener stubs for compatibility. | 40 | + | const internalDarkModeScript = `(function() { |
| 68 | - | const darkModeScript = `(function() { | |||
| 69 | var prefersDark = window.__dumber_gtk_prefers_dark || false; | 41 | var prefersDark = window.__dumber_gtk_prefers_dark || false; | ||
| 70 | var originalMatchMedia = window.matchMedia.bind(window); | 42 | var originalMatchMedia = window.matchMedia.bind(window); | ||
| 71 | 43 | ||||
| 72 | - | // Apply dark/light class to document element for CSS theming | 44 | + | // Apply dark/light class to document element for Tailwind CSS theming |
| 73 | if (prefersDark) { | 45 | if (prefersDark) { | ||
| 74 | document.documentElement.classList.add('dark'); | 46 | document.documentElement.classList.add('dark'); | ||
| 75 | document.documentElement.classList.remove('light'); | 47 | document.documentElement.classList.remove('light'); | ||
| @@ -78,20 +50,7 @@ const darkModeScript = `(function() { | |||||
| 78 | document.documentElement.classList.remove('dark'); | 50 | document.documentElement.classList.remove('dark'); | ||
| 79 | } | 51 | } | ||
| 80 | 52 | ||||
| 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 | 53 | // Helper: Check if query is a prefers-color-scheme query | ||
| 94 | - | // Normalizes by removing whitespace and lowercasing | |||
| 95 | function isColorSchemeQuery(query, scheme) { | 54 | function isColorSchemeQuery(query, scheme) { | ||
| 96 | if (typeof query !== 'string') return false; | 55 | if (typeof query !== 'string') return false; | ||
| 97 | var normalized = query.replace(/\s+/g, '').toLowerCase(); | 56 | var normalized = query.replace(/\s+/g, '').toLowerCase(); | ||
| @@ -108,7 +67,6 @@ const darkModeScript = `(function() { | |||||
| 108 | media: query, | 67 | media: query, | ||
| 109 | get onchange() { return onchangeHandler; }, | 68 | get onchange() { return onchangeHandler; }, | ||
| 110 | set onchange(fn) { onchangeHandler = fn; }, | 69 | set onchange(fn) { onchangeHandler = fn; }, | ||
| 111 | - | // Deprecated but still used by some sites | |||
| 112 | addListener: function(cb) { | 70 | addListener: function(cb) { | ||
| 113 | if (typeof cb === 'function') listeners.push(cb); | 71 | if (typeof cb === 'function') listeners.push(cb); | ||
| 114 | }, | 72 | }, | ||
| @@ -116,7 +74,6 @@ const darkModeScript = `(function() { | |||||
| 116 | var idx = listeners.indexOf(cb); | 74 | var idx = listeners.indexOf(cb); | ||
| 117 | if (idx !== -1) listeners.splice(idx, 1); | 75 | if (idx !== -1) listeners.splice(idx, 1); | ||
| 118 | }, | 76 | }, | ||
| 119 | - | // Modern API | |||
| 120 | addEventListener: function(type, cb) { | 77 | addEventListener: function(type, cb) { | ||
| 121 | if (type === 'change' && typeof cb === 'function') { | 78 | if (type === 'change' && typeof cb === 'function') { | ||
| 122 | listeners.push(cb); | 79 | listeners.push(cb); | ||
| @@ -152,15 +109,18 @@ const darkModeScript = `(function() { | |||||
| 152 | }; | 109 | }; | ||
| 153 | })();` | 110 | })();` | ||
| 154 | 111 | ||||
| 112 | + | // internalPageAllowList restricts script injection to internal dumb:// pages only. | |||
| 113 | + | var internalPageAllowList = []string{"dumb://*"} | |||
| 114 | + | ||||
| 155 | // ContentInjector encapsulates script injection into WebViews. | 115 | // ContentInjector encapsulates script injection into WebViews. | ||
| 156 | - | // It injects minimal scripts for dark mode detection in web pages | 116 | + | // It injects dark mode detection scripts for internal pages (dumb://) |
| 157 | - | // and theme CSS variables for internal pages (dumb://). | 117 | + | // and theme CSS variables for WebUI styling. |
| 118 | + | // External pages receive dark mode preference via libadwaita's StyleManager. | |||
| 158 | // Implements port.ContentInjector interface. | 119 | // Implements port.ContentInjector interface. | ||
| 159 | type ContentInjector struct { | 120 | type ContentInjector struct { | ||
| 160 | prefersDark bool | 121 | prefersDark bool | ||
| 161 | themeCSSVars string // CSS custom property declarations for WebUI | 122 | themeCSSVars string // CSS custom property declarations for WebUI | ||
| 162 | findCSS string // CSS for find-in-page highlight styling | 123 | findCSS string // CSS for find-in-page highlight styling | ||
| 163 | - | bgColor string // Background color hex for early injection (prevents white flash) | |||
| 164 | } | 124 | } | ||
| 165 | 125 | ||||
| 166 | // NewContentInjector creates a new injector instance. | 126 | // NewContentInjector creates a new injector instance. | ||
| @@ -200,18 +160,14 @@ func (ci *ContentInjector) SetPrefersDark(prefersDark bool) { | |||||
| 200 | ci.prefersDark = prefersDark | 160 | ci.prefersDark = prefersDark | ||
| 201 | } | 161 | } | ||
| 202 | 162 | ||||
| 203 | - | // SetBackgroundColor sets the background color for early CSS injection. | 163 | + | // InjectScripts adds scripts to the given content manager. |
| 204 | - | // This color is applied to html/body at document start to prevent white flash. | 164 | + | // For internal pages (dumb://*): |
| 205 | - | // The hex color should include the # prefix (e.g., "#0a0a0b"). | 165 | + | // - Injects dark mode detection (class on <html>, matchMedia patch) |
| 206 | - | func (ci *ContentInjector) SetBackgroundColor(hex string) { | 166 | + | // - Injects theme CSS variables for WebUI styling |
| 207 | - | ci.bgColor = hex | 167 | + | // |
| 208 | - | } | 168 | + | // For external pages: |
| 209 | - | 169 | + | // - Only injects find highlight CSS | |
| 210 | - | // InjectScripts adds the minimal dark mode detection scripts to the given content manager. | 170 | + | // - Dark mode is handled natively via libadwaita's StyleManager |
| 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) | |||
| 215 | func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) { | 171 | func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) { | ||
| 216 | log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger() | 172 | log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger() | ||
| 217 | 173 | ||||
| @@ -229,35 +185,20 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 229 | log.Debug().Str("script", label).Msg("injected user script") | 185 | log.Debug().Str("script", label).Msg("injected user script") | ||
| 230 | } | 186 | } | ||
| 231 | 187 | ||||
| 232 | - | // 1. Inject early background color to prevent white flash (must be first!) | 188 | + | // 1. Inject GTK dark mode preference for internal pages only |
| 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) | 189 | darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark) | ||
| 249 | addScript( | 190 | addScript( | ||
| 250 | webkit.NewUserScript( | 191 | webkit.NewUserScript( | ||
| 251 | darkModePrefScript, | 192 | darkModePrefScript, | ||
| 252 | webkit.UserContentInjectTopFrameValue, | 193 | webkit.UserContentInjectTopFrameValue, | ||
| 253 | webkit.UserScriptInjectAtDocumentStartValue, | 194 | webkit.UserScriptInjectAtDocumentStartValue, | ||
| 254 | - | nil, | 195 | + | internalPageAllowList, |
| 255 | nil, | 196 | nil, | ||
| 256 | ), | 197 | ), | ||
| 257 | "gtk-dark-mode-pref", | 198 | "gtk-dark-mode-pref", | ||
| 258 | ) | 199 | ) | ||
| 259 | 200 | ||||
| 260 | - | // 3. Inject WebView ID for debugging | 201 | + | // 2. Inject WebView ID for debugging (internal pages only) |
| 261 | if webviewID != 0 { | 202 | if webviewID != 0 { | ||
| 262 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | 203 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | ||
| 263 | addScript( | 204 | addScript( | ||
| @@ -265,26 +206,27 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 265 | idScript, | 206 | idScript, | ||
| 266 | webkit.UserContentInjectTopFrameValue, | 207 | webkit.UserContentInjectTopFrameValue, | ||
| 267 | webkit.UserScriptInjectAtDocumentStartValue, | 208 | webkit.UserScriptInjectAtDocumentStartValue, | ||
| 268 | - | nil, | 209 | + | internalPageAllowList, |
| 269 | nil, | 210 | nil, | ||
| 270 | ), | 211 | ), | ||
| 271 | "webview-id", | 212 | "webview-id", | ||
| 272 | ) | 213 | ) | ||
| 273 | } | 214 | } | ||
| 274 | 215 | ||||
| 275 | - | // 4. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark) | 216 | + | // 3. Inject dark mode handler for internal pages only |
| 217 | + | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | |||
| 276 | addScript( | 218 | addScript( | ||
| 277 | webkit.NewUserScript( | 219 | webkit.NewUserScript( | ||
| 278 | - | darkModeScript, | 220 | + | internalDarkModeScript, |
| 279 | webkit.UserContentInjectTopFrameValue, | 221 | webkit.UserContentInjectTopFrameValue, | ||
| 280 | webkit.UserScriptInjectAtDocumentStartValue, | 222 | webkit.UserScriptInjectAtDocumentStartValue, | ||
| 281 | - | nil, | 223 | + | internalPageAllowList, |
| 282 | nil, | 224 | nil, | ||
| 283 | ), | 225 | ), | ||
| 284 | - | "dark-mode-handler", | 226 | + | "internal-dark-mode-handler", |
| 285 | ) | 227 | ) | ||
| 286 | 228 | ||||
| 287 | - | // 5. Inject theme CSS for internal pages (dumb://* only) | 229 | + | // 4. Inject theme CSS for internal pages (dumb://* only) |
| 288 | if ci.themeCSSVars != "" { | 230 | if ci.themeCSSVars != "" { | ||
| 289 | // Escape for JS string literal | 231 | // Escape for JS string literal | ||
| 290 | escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\") | 232 | escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\") | ||
| @@ -296,15 +238,15 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 296 | themeCSSInjectionScript, | 238 | themeCSSInjectionScript, | ||
| 297 | webkit.UserContentInjectTopFrameValue, | 239 | webkit.UserContentInjectTopFrameValue, | ||
| 298 | webkit.UserScriptInjectAtDocumentEndValue, | 240 | webkit.UserScriptInjectAtDocumentEndValue, | ||
| 299 | - | nil, // Inject on all pages - allowlist patterns don't work for custom schemes | 241 | + | internalPageAllowList, |
| 300 | nil, | 242 | nil, | ||
| 301 | ), | 243 | ), | ||
| 302 | "theme-css-vars", | 244 | "theme-css-vars", | ||
| 303 | ) | 245 | ) | ||
| 304 | - | log.Debug().Msg("theme CSS vars injection configured") | 246 | + | log.Debug().Msg("theme CSS vars injection configured for internal pages") |
| 305 | } | 247 | } | ||
| 306 | 248 | ||||
| 307 | - | // 6. Inject find highlight CSS for all pages | 249 | + | // 5. Inject find highlight CSS for all pages |
| 308 | if ci.findCSS != "" { | 250 | if ci.findCSS != "" { | ||
| 309 | stylesheet := webkit.NewUserStyleSheet( | 251 | stylesheet := webkit.NewUserStyleSheet( | ||
| 310 | ci.findCSS, | 252 | ci.findCSS, | ||
| @@ -321,5 +263,5 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 321 | } | 263 | } | ||
| 322 | } | 264 | } | ||
| 323 | 265 | ||||
| 324 | - | log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected") | 266 | + | log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("scripts injected") |
| 325 | } | 267 | } | ||
M internal/ui/app.go +31 -10
| @@ -27,6 +27,7 @@ import ( | |||
| 27 | 27 | "github.com/bnema/dumber/internal/ui/layout" | |
| 28 | 28 | "github.com/bnema/dumber/internal/ui/theme" | |
| 29 | 29 | "github.com/bnema/dumber/internal/ui/window" | |
| 30 | + | "github.com/jwijenbergh/puregotk/v4/adw" | |
| 30 | 31 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 31 | 32 | "github.com/jwijenbergh/puregotk/v4/gio" | |
| 32 | 33 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| @@ -217,23 +218,43 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 217 | 218 | func (a *App) applyGTKColorSchemePreference(ctx context.Context) { | |
| 218 | 219 | log := logging.FromContext(ctx) | |
| 219 | 220 | ||
| 220 | - | // Propagate app color scheme preference to GTK (and WebKit) | |
| 221 | - | // so web content can correctly evaluate prefers-color-scheme. | |
| 222 | - | settings := gtk.SettingsGetDefault() | |
| 223 | - | if settings == nil { | |
| 224 | - | return | |
| 225 | - | } | |
| 221 | + | // Initialize libadwaita (required before using StyleManager). | |
| 222 | + | // This also initializes GTK implicitly. | |
| 223 | + | adw.Init() | |
| 226 | 224 | ||
| 227 | - | scheme := "default" | |
| 225 | + | // Get the config color scheme preference | |
| 226 | + | scheme := "system" | |
| 228 | 227 | if a.deps != nil && a.deps.Config != nil && a.deps.Config.Appearance.ColorScheme != "" { | |
| 229 | 228 | scheme = a.deps.Config.Appearance.ColorScheme | |
| 230 | 229 | } | |
| 231 | - | prefersDark := theme.ResolveColorScheme(scheme) | |
| 232 | - | settings.SetPropertyGtkApplicationPreferDarkTheme(prefersDark) | |
| 230 | + | ||
| 231 | + | // Apply color scheme via libadwaita's StyleManager. | |
| 232 | + | // This properly communicates the preference to GTK and WebKit, | |
| 233 | + | // so web content can correctly evaluate prefers-color-scheme media queries. | |
| 234 | + | styleMgr := adw.StyleManagerGetDefault() | |
| 235 | + | if styleMgr == nil { | |
| 236 | + | log.Warn().Msg("failed to get adw.StyleManager") | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | var adwScheme adw.ColorScheme | |
| 241 | + | switch scheme { | |
| 242 | + | case "dark", "prefer-dark": | |
| 243 | + | adwScheme = adw.ColorSchemeForceDarkValue | |
| 244 | + | case "light", "prefer-light": | |
| 245 | + | adwScheme = adw.ColorSchemeForceLightValue | |
| 246 | + | default: | |
| 247 | + | // "system" or empty - follow system preference | |
| 248 | + | adwScheme = adw.ColorSchemePreferLightValue | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | styleMgr.SetColorScheme(adwScheme) | |
| 252 | + | prefersDark := styleMgr.GetDark() | |
| 253 | + | ||
| 233 | 254 | log.Debug(). | |
| 234 | 255 | Str("scheme", scheme). | |
| 235 | 256 | Bool("prefers_dark", prefersDark). | |
| 236 | - | Msg("set gtk-application-prefer-dark-theme") | |
| 257 | + | Msg("applied color scheme via adw.StyleManager") | |
| 237 | 258 | } | |
| 238 | 259 | ||
| 239 | 260 | func (a *App) setupPoolBackgroundColor(ctx context.Context) { | |
| @@ -27,6 +27,7 @@ import ( | |||||
| 27 | "github.com/bnema/dumber/internal/ui/layout" | 27 | "github.com/bnema/dumber/internal/ui/layout" | ||
| 28 | "github.com/bnema/dumber/internal/ui/theme" | 28 | "github.com/bnema/dumber/internal/ui/theme" | ||
| 29 | "github.com/bnema/dumber/internal/ui/window" | 29 | "github.com/bnema/dumber/internal/ui/window" | ||
| 30 | + | "github.com/jwijenbergh/puregotk/v4/adw" | |||
| 30 | "github.com/jwijenbergh/puregotk/v4/gdk" | 31 | "github.com/jwijenbergh/puregotk/v4/gdk" | ||
| 31 | "github.com/jwijenbergh/puregotk/v4/gio" | 32 | "github.com/jwijenbergh/puregotk/v4/gio" | ||
| 32 | "github.com/jwijenbergh/puregotk/v4/glib" | 33 | "github.com/jwijenbergh/puregotk/v4/glib" | ||
| @@ -217,23 +218,43 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 217 | func (a *App) applyGTKColorSchemePreference(ctx context.Context) { | 218 | func (a *App) applyGTKColorSchemePreference(ctx context.Context) { | ||
| 218 | log := logging.FromContext(ctx) | 219 | log := logging.FromContext(ctx) | ||
| 219 | 220 | ||||
| 220 | - | // Propagate app color scheme preference to GTK (and WebKit) | 221 | + | // Initialize libadwaita (required before using StyleManager). |
| 221 | - | // so web content can correctly evaluate prefers-color-scheme. | 222 | + | // This also initializes GTK implicitly. |
| 222 | - | settings := gtk.SettingsGetDefault() | 223 | + | adw.Init() |
| 223 | - | if settings == nil { | |||
| 224 | - | return | |||
| 225 | - | } | |||
| 226 | 224 | ||||
| 227 | - | scheme := "default" | 225 | + | // Get the config color scheme preference |
| 226 | + | scheme := "system" | |||
| 228 | if a.deps != nil && a.deps.Config != nil && a.deps.Config.Appearance.ColorScheme != "" { | 227 | if a.deps != nil && a.deps.Config != nil && a.deps.Config.Appearance.ColorScheme != "" { | ||
| 229 | scheme = a.deps.Config.Appearance.ColorScheme | 228 | scheme = a.deps.Config.Appearance.ColorScheme | ||
| 230 | } | 229 | } | ||
| 231 | - | prefersDark := theme.ResolveColorScheme(scheme) | 230 | + | |
| 232 | - | settings.SetPropertyGtkApplicationPreferDarkTheme(prefersDark) | 231 | + | // Apply color scheme via libadwaita's StyleManager. |
| 232 | + | // This properly communicates the preference to GTK and WebKit, | |||
| 233 | + | // so web content can correctly evaluate prefers-color-scheme media queries. | |||
| 234 | + | styleMgr := adw.StyleManagerGetDefault() | |||
| 235 | + | if styleMgr == nil { | |||
| 236 | + | log.Warn().Msg("failed to get adw.StyleManager") | |||
| 237 | + | return | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | var adwScheme adw.ColorScheme | |||
| 241 | + | switch scheme { | |||
| 242 | + | case "dark", "prefer-dark": | |||
| 243 | + | adwScheme = adw.ColorSchemeForceDarkValue | |||
| 244 | + | case "light", "prefer-light": | |||
| 245 | + | adwScheme = adw.ColorSchemeForceLightValue | |||
| 246 | + | default: | |||
| 247 | + | // "system" or empty - follow system preference | |||
| 248 | + | adwScheme = adw.ColorSchemePreferLightValue | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | styleMgr.SetColorScheme(adwScheme) | |||
| 252 | + | prefersDark := styleMgr.GetDark() | |||
| 253 | + | ||||
| 233 | log.Debug(). | 254 | log.Debug(). | ||
| 234 | Str("scheme", scheme). | 255 | Str("scheme", scheme). | ||
| 235 | Bool("prefers_dark", prefersDark). | 256 | Bool("prefers_dark", prefersDark). | ||
| 236 | - | Msg("set gtk-application-prefer-dark-theme") | 257 | + | Msg("applied color scheme via adw.StyleManager") |
| 237 | } | 258 | } | ||
| 238 | 259 | ||||
| 239 | func (a *App) setupPoolBackgroundColor(ctx context.Context) { | 260 | func (a *App) setupPoolBackgroundColor(ctx context.Context) { | ||