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://home/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
76 // Keep the global flag in sync
77 window.__dumber_gtk_prefers_dark = prefersDark;
78
79 // Update dark/light class
80 if (prefersDark) {
81 document.documentElement.classList.add('dark');
82 document.documentElement.classList.remove('light');
83 } else {
84 document.documentElement.classList.add('light');
85 document.documentElement.classList.remove('dark');
86 }
87
88 // Update or insert theme style
89 var style = document.querySelector('style[data-dumber-theme-vars]');
90 if (!style) {
91 style = document.createElement('style');
92 style.setAttribute('data-dumber-theme-vars', '');
93 (document.head || document.documentElement).appendChild(style);
94 }
95 style.textContent = cssText;
96
97 // Notify any running WebUI that theme changed
98 try {
99 window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
100 detail: { prefersDark: prefersDark }
101 }));
102 } catch (e) {
103 // ignore
104 }
105
106 // Keep color-scheme consistent
107 var meta = document.querySelector('meta[name="color-scheme"]');
108 if (!meta) {
109 meta = document.createElement('meta');
110 meta.name = 'color-scheme';
111 (document.head || document.documentElement).appendChild(meta);
112 }
113 meta.content = prefersDark ? 'dark light' : 'light dark';
114 } catch (e) {
115 console.error('[dumber] failed to apply theme', e);
116 }
117})();`, string(cssJSON), prefersDark)
118
119 return script, nil
120}
121
122func (c *Coordinator) applyWebUITheme(
123 ctx context.Context,
124 paneID entity.PaneID,
125 wv port.WebView,
126 script string,
127 prefersDark bool,
128) {
129 if wv == nil || wv.IsDestroyed() {
130 return
131 }
132 uri := wv.URI()
133 if !strings.HasPrefix(uri, "dumb://") {
134 return
135 }
136 wv.RunJavaScript(ctx, script)
137 logging.FromContext(ctx).
138 Debug().
139 Str("pane_id", string(paneID)).
140 Str("uri", uri).
141 Bool("prefers_dark", prefersDark).
142 Msg("applied WebUI theme")
143}
144
145func (c *Coordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
146 c.appearanceMu.Lock()
147 if c.pendingThemePanes == nil {
148 c.pendingThemePanes = make(map[entity.PaneID]bool)
149 }
150 c.pendingThemePanes[paneID] = true
151 c.pendingThemeUpdate = pendingThemeUpdate{
152 prefersDark: prefersDark,
153 cssText: cssText,
154 }
155 c.hasPendingThemeUpdate = true
156 c.appearanceMu.Unlock()
157}
158
159func (c *Coordinator) setCurrentTheme(prefersDark bool, cssText string) {
160 c.appearanceMu.Lock()
161 c.currentTheme = pendingThemeUpdate{
162 prefersDark: prefersDark,
163 cssText: cssText,
164 }
165 c.hasCurrentTheme = true
166 c.appearanceMu.Unlock()
167}
168
169func (c *Coordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
170 c.appearanceMu.Lock()
171 defer c.appearanceMu.Unlock()
172
173 if !c.hasCurrentTheme {
174 return pendingThemeUpdate{}, false
175 }
176 return c.currentTheme, true
177}
178
179func (c *Coordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
180 c.appearanceMu.Lock()
181 defer c.appearanceMu.Unlock()
182
183 if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
184 return pendingThemeUpdate{}, false
185 }
186 delete(c.pendingThemePanes, paneID)
187 update := c.pendingThemeUpdate
188 if len(c.pendingThemePanes) == 0 {
189 c.hasPendingThemeUpdate = false
190 }
191 return update, true
192}
193
194func (c *Coordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv port.WebView) bool {
195 update, ok := c.takePendingThemeApply(paneID)
196 if !ok {
197 return false
198 }
199
200 script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
201 if err != nil {
202 logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
203 return false
204 }
205 c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
206 return true
207}
208
209func (c *Coordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv port.WebView) bool {
210 update, ok := c.getCurrentTheme()
211 if !ok || update.cssText == "" {
212 return false
213 }
214
215 script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
216 if err != nil {
217 logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
218 return false
219 }
220 c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
221 return true
222}
223
224func (c *Coordinator) queueScriptRefresh(paneID entity.PaneID) {
225 c.appearanceMu.Lock()
226 if c.pendingScriptRefresh == nil {
227 c.pendingScriptRefresh = make(map[entity.PaneID]bool)
228 }
229 c.pendingScriptRefresh[paneID] = true
230 c.appearanceMu.Unlock()
231}
232
233func (c *Coordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
234 c.appearanceMu.Lock()
235 defer c.appearanceMu.Unlock()
236
237 if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
238 return false
239 }
240 delete(c.pendingScriptRefresh, paneID)
241 return true
242}
243
244func (c *Coordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
245 if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
246 return
247 }
248 if !c.takePendingScriptRefresh(paneID) {
249 return
250 }
251 if c.injector == nil {
252 return
253 }
254 c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
255}
256
257func (c *Coordinator) shouldDeferAppearance(wv port.WebView) bool {
258 if wv == nil || wv.IsDestroyed() {
259 return false
260 }
261 if wv.IsLoading() {
262 return true
263 }
264 return wv.EstimatedProgress() < 1.0
265}
266
267func (c *Coordinator) refreshInjectedScripts(
268 ctx context.Context,
269 injector port.ContentInjector,
270 paneID entity.PaneID,
271 wv port.WebView,
272) {
273 if injector == nil || wv == nil || wv.IsDestroyed() {
274 return
275 }
276 if err := injector.RefreshScripts(ctx, wv); err != nil {
277 logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to refresh injected scripts")
278 return
279 }
280 logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
281}
282
283func (c *Coordinator) clearPendingAppearance(paneID entity.PaneID) {
284 c.appearanceMu.Lock()
285 if c.pendingScriptRefresh != nil {
286 delete(c.pendingScriptRefresh, paneID)
287 }
288 if c.pendingThemePanes != nil {
289 delete(c.pendingThemePanes, paneID)
290 if len(c.pendingThemePanes) == 0 {
291 c.hasPendingThemeUpdate = false
292 }
293 }
294 c.appearanceMu.Unlock()
295}