1package content
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "maps"
8 "strings"
9 "time"
10
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/logging"
14)
15
16const (
17 aboutBlankURI = "about:blank"
18 crashPageURI = "dumb://history/crash"
19 logURLMaxLen = 80
20 oauthParentRefreshDebounce = 200 * time.Millisecond
21
22 // Dark theme background color (#0a0a0b) as float32 RGBA values
23 darkBgR = 0.039
24 darkBgG = 0.039
25 darkBgB = 0.043
26 darkBgA = 1.0
27)
28
29// ApplyWebUIThemeToAll updates theme CSS for already-loaded dumb:// pages.
30// This is necessary because user scripts only run on navigation.
31func (c *Coordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDark bool, cssText string) {
32 log := logging.FromContext(ctx)
33 if cssText == "" {
34 return
35 }
36
37 c.setCurrentTheme(prefersDark, cssText)
38
39 script, err := buildWebUIThemeScript(prefersDark, cssText)
40 if err != nil {
41 log.Warn().Err(err).Msg("failed to build WebUI theme script")
42 return
43 }
44
45 // Snapshot webViews under lock to avoid data race with concurrent popup create/close.
46 c.webViewsMu.RLock()
47 snapshot := make(map[entity.PaneID]port.WebView, len(c.webViews))
48 maps.Copy(snapshot, c.webViews)
49 c.webViewsMu.RUnlock()
50
51 for paneID, wv := range snapshot {
52 if wv == nil || wv.IsDestroyed() {
53 continue
54 }
55 if c.shouldDeferAppearance(wv) {
56 c.queueThemeApply(paneID, prefersDark, cssText)
57 log.Debug().Str("pane_id", string(paneID)).Msg("deferred WebUI theme apply until load committed")
58 continue
59 }
60 c.applyWebUITheme(ctx, paneID, wv, script, prefersDark)
61 }
62}
63
64func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
65 cssJSON, err := json.Marshal(cssText)
66 if err != nil {
67 return "", err
68 }
69
70 script := fmt.Sprintf(`(function(){
71 try {
72 var cssText = %s;
73 var prefersDark = %t;
74 var root = document.documentElement;
75
76 // Keep the global flags in sync for both WebKit and CEF-backed pages.
77 window.__dumber_gtk_prefers_dark = prefersDark;
78 window.__dumber_cef_prefers_dark = prefersDark;
79
80 // Update dark/light class
81 if (prefersDark) {
82 root.classList.add('dark');
83 root.classList.remove('light');
84 } else {
85 root.classList.add('light');
86 root.classList.remove('dark');
87 }
88 root.style.colorScheme = prefersDark ? 'dark' : 'light';
89
90 // Update or insert theme style
91 var style = document.getElementById('dumber-theme-vars') || document.querySelector('style[data-dumber-theme-vars]');
92 if (!style) {
93 style = document.createElement('style');
94 style.id = 'dumber-theme-vars';
95 style.setAttribute('data-dumber-theme-vars', '');
96 (document.head || document.documentElement).appendChild(style);
97 }
98 style.textContent = cssText;
99
100 // Notify any running WebUI that theme changed
101 try {
102 window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
103 detail: { prefersDark: prefersDark }
104 }));
105 } catch (e) {
106 // ignore
107 }
108
109 // Keep color-scheme consistent
110 var meta = document.querySelector('meta[name="color-scheme"]');
111 if (!meta) {
112 meta = document.createElement('meta');
113 meta.name = 'color-scheme';
114 (document.head || document.documentElement).appendChild(meta);
115 }
116 meta.content = prefersDark ? 'dark light' : 'light dark';
117 } catch (e) {
118 console.error('[dumber] failed to apply theme', e);
119 }
120})();`, string(cssJSON), prefersDark)
121
122 return script, nil
123}
124
125func (c *Coordinator) applyWebUITheme(
126 ctx context.Context,
127 paneID entity.PaneID,
128 wv port.WebView,
129 script string,
130 prefersDark bool,
131) {
132 if wv == nil || wv.IsDestroyed() {
133 return
134 }
135 uri := wv.URI()
136 if !strings.HasPrefix(uri, "dumb://") {
137 return
138 }
139 wv.RunJavaScript(ctx, script)
140 logging.FromContext(ctx).
141 Debug().
142 Str("pane_id", string(paneID)).
143 Str("uri", uri).
144 Bool("prefers_dark", prefersDark).
145 Msg("applied WebUI theme")
146}
147
148func (c *Coordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
149 c.appearanceMu.Lock()
150 if c.pendingThemePanes == nil {
151 c.pendingThemePanes = make(map[entity.PaneID]bool)
152 }
153 c.pendingThemePanes[paneID] = true
154 c.pendingThemeUpdate = pendingThemeUpdate{
155 prefersDark: prefersDark,
156 cssText: cssText,
157 }
158 c.hasPendingThemeUpdate = true
159 c.appearanceMu.Unlock()
160}
161
162func (c *Coordinator) setCurrentTheme(prefersDark bool, cssText string) {
163 c.appearanceMu.Lock()
164 c.currentTheme = pendingThemeUpdate{
165 prefersDark: prefersDark,
166 cssText: cssText,
167 }
168 c.hasCurrentTheme = true
169 c.appearanceMu.Unlock()
170}
171
172func (c *Coordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
173 c.appearanceMu.Lock()
174 defer c.appearanceMu.Unlock()
175
176 if !c.hasCurrentTheme {
177 return pendingThemeUpdate{}, false
178 }
179 return c.currentTheme, true
180}
181
182func (c *Coordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
183 c.appearanceMu.Lock()
184 defer c.appearanceMu.Unlock()
185
186 if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
187 return pendingThemeUpdate{}, false
188 }
189 delete(c.pendingThemePanes, paneID)
190 update := c.pendingThemeUpdate
191 if len(c.pendingThemePanes) == 0 {
192 c.hasPendingThemeUpdate = false
193 }
194 return update, true
195}
196
197func (c *Coordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv port.WebView) bool {
198 update, ok := c.takePendingThemeApply(paneID)
199 if !ok {
200 return false
201 }
202
203 script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
204 if err != nil {
205 logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
206 return false
207 }
208 c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
209 return true
210}
211
212func (c *Coordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv port.WebView) bool {
213 update, ok := c.getCurrentTheme()
214 if !ok || update.cssText == "" {
215 return false
216 }
217
218 script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
219 if err != nil {
220 logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
221 return false
222 }
223 c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
224 return true
225}
226
227func (c *Coordinator) queueScriptRefresh(paneID entity.PaneID) {
228 c.appearanceMu.Lock()
229 if c.pendingScriptRefresh == nil {
230 c.pendingScriptRefresh = make(map[entity.PaneID]bool)
231 }
232 c.pendingScriptRefresh[paneID] = true
233 c.appearanceMu.Unlock()
234}
235
236func (c *Coordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
237 c.appearanceMu.Lock()
238 defer c.appearanceMu.Unlock()
239
240 if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
241 return false
242 }
243 delete(c.pendingScriptRefresh, paneID)
244 return true
245}
246
247func (c *Coordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
248 if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
249 return
250 }
251 if !c.takePendingScriptRefresh(paneID) {
252 return
253 }
254 if c.injector == nil {
255 return
256 }
257 c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
258}
259
260func (c *Coordinator) shouldDeferAppearance(wv port.WebView) bool {
261 if wv == nil || wv.IsDestroyed() {
262 return false
263 }
264 if wv.IsLoading() {
265 return true
266 }
267 return wv.EstimatedProgress() < 1.0
268}
269
270func (c *Coordinator) refreshInjectedScripts(
271 ctx context.Context,
272 injector port.ContentInjector,
273 paneID entity.PaneID,
274 wv port.WebView,
275) {
276 if injector == nil || wv == nil || wv.IsDestroyed() {
277 return
278 }
279 if err := injector.RefreshScripts(ctx, wv); err != nil {
280 logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to refresh injected scripts")
281 return
282 }
283 logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
284}
285
286func (c *Coordinator) clearPendingAppearance(paneID entity.PaneID) {
287 c.appearanceMu.Lock()
288 if c.pendingScriptRefresh != nil {
289 delete(c.pendingScriptRefresh, paneID)
290 }
291 if c.pendingThemePanes != nil {
292 delete(c.pendingThemePanes, paneID)
293 if len(c.pendingThemePanes) == 0 {
294 c.hasPendingThemeUpdate = false
295 }
296 }
297 c.appearanceMu.Unlock()
298}