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