1package content
2
3import (
4 "context"
5 neturl "net/url"
6 "strings"
7
8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"
11 urlutil "github.com/bnema/dumber/internal/domain/url"
12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/dumber/internal/ui/component"
14 "github.com/bnema/puregotk/v4/gtk"
15)
16
17// onLoadCommitted re-applies zoom when page content starts loading and records history.
18// WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
19// History is recorded here because the URI is guaranteed to be correct after commit.
20// Also shows the WebView widget (it's hidden during creation to avoid white flash).
21func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity) {
22 log := logging.FromContext(ctx)
23
24 uri := wv.URI()
25 if uri == "" {
26 return
27 }
28
29 // Set appropriate background color based on page type to prevent dark background bleeding.
30 switch {
31 case strings.HasPrefix(uri, "dumb://"):
32 // Internal pages: apply themed background
33 theme, ok := c.getCurrentTheme()
34 if ok && theme.prefersDark {
35 wv.SetBackgroundColor(darkBgR, darkBgG, darkBgB, darkBgA)
36 } else {
37 wv.ResetBackgroundToDefault()
38 }
39 case strings.HasPrefix(uri, "about:"):
40 // Keep pool background (no action)
41 default:
42 // External pages: white background
43 wv.ResetBackgroundToDefault()
44 }
45
46 // Show the WebView now that content is being painted
47 // (WebViews are hidden on creation to avoid white flash)
48 // Skip showing if this is about:blank but the pane is loading a different URL
49 // This prevents the brief flash of about:blank during initial navigation
50 shouldShow := true
51 if uri == aboutBlankURI {
52 // Get the pane's intended URI from the workspace
53 ws, _ := c.getActiveWS()
54 if ws != nil {
55 if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
56 // Don't show about:blank if the pane is supposed to load a different URL
57 if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
58 shouldShow = false
59 log.Debug().
60 Str("pane_id", string(paneID)).
61 Str("pane_uri", paneNode.Pane.URI).
62 Msg("skipping webview show for about:blank (pane loading different URL)")
63 }
64 }
65 }
66 }
67
68 if !shouldShow {
69 // Avoid updating UI/domain state to about:blank when we know the pane is
70 // navigating to a different URL. This prevents the omnibox/window title from
71 // briefly showing about:blank on cold start.
72 c.clearPendingReveal(paneID, identity)
73 return
74 }
75
76 if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
77 c.applyCurrentTheme(ctx, paneID, wv)
78 }
79
80 c.markPendingReveal(paneID, identity)
81 c.revealIfPending(ctx, paneID, wv, identity, uri, "progress-after-commit")
82
83 // Update domain model with current URI for session snapshots
84 c.updatePaneURI(paneID, uri)
85
86 // Sync StackedView title bar with the WebView's current title.
87 // This keeps the stacked title bar up-to-date immediately on navigation,
88 // before the asynchronous notify::title signal fires.
89 if title := wv.Title(); title != "" {
90 c.syncStackedTitle(ctx, paneID, title)
91 }
92
93 // Record history - URI is guaranteed to be correct at LoadCommitted
94 if c.onHistoryRecord != nil {
95 c.onHistoryRecord(ctx, paneID, uri)
96 }
97
98 // Resolve favicon for stacked title bar (fills gap when WebKit doesn't emit notify::favicon)
99 c.resolveCommittedFavicon(ctx, paneID, wv)
100
101 // Notify active pane navigation for permission indicator reset.
102 c.notifyActiveNavigation(paneID, uri)
103
104 // Apply zoom
105 if c.zoomUC == nil {
106 return
107 }
108
109 zoomKey, err := usecase.ExtractZoomKey(uri)
110 if err != nil {
111 log.Debug().Err(err).Str("uri", uri).Msg("skipping zoom application: cannot extract zoom key")
112 return
113 }
114
115 _ = c.zoomUC.ApplyToWebView(ctx, wv, zoomKey)
116}
117
118func (c *Coordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {
119 if c.onActiveNavigationCommitted == nil {
120 return
121 }
122 c.activePaneOverrideMu.RLock()
123 override := c.activePaneOverride
124 c.activePaneOverrideMu.RUnlock()
125 if override != "" {
126 if override == paneID {
127 c.onActiveNavigationCommitted(paneID, uri)
128 }
129 return
130 }
131 ws, _ := c.getActiveWS()
132 if ws != nil && ws.ActivePaneID == paneID {
133 c.onActiveNavigationCommitted(paneID, uri)
134 }
135}
136
137func (c *Coordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv port.WebView) bool {
138 if wv == nil || wv.IsDestroyed() {
139 return false
140 }
141 if wv.URI() != aboutBlankURI {
142 return false
143 }
144 ws, _ := c.getActiveWS()
145 if ws == nil {
146 return false
147 }
148 paneNode := ws.FindPane(paneID)
149 if paneNode == nil || paneNode.Pane == nil {
150 return false
151 }
152 if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
153 return true
154 }
155 return false
156}
157
158// onSPANavigation records history when URL changes via JavaScript (History API).
159// This handles SPA navigation like YouTube search, where the URL changes without a page load.
160func (c *Coordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) {
161 // Update domain model with current URI for session snapshots
162 c.updatePaneURI(paneID, uri)
163
164 // Record history for SPA navigation
165 if c.onHistoryRecord != nil {
166 c.onHistoryRecord(ctx, paneID, uri)
167 }
168}
169
170// updatePaneURI updates the pane's URI in the domain model.
171// This is called on navigation so that session snapshots capture the current URL.
172func (c *Coordinator) updatePaneURI(paneID entity.PaneID, uri string) {
173 if c.onPaneURIUpdated != nil {
174 c.onPaneURIUpdated(paneID, uri)
175 }
176}
177
178// onLoadStarted shows the progress bar when page loading begins.
179func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
180 // Trigger deferred initialization on first load_started.
181 // This ensures non-critical init runs after initial navigation starts.
182 c.loadStartedOnce.Do(func() {
183 if c.onFirstLoadStarted != nil {
184 c.onFirstLoadStarted()
185 }
186 })
187
188 _, wsView := c.getActiveWS()
189 var paneView *component.PaneView
190 if wsView != nil {
191 paneView = wsView.GetPaneView(paneID)
192 }
193
194 if wsView == nil {
195 return
196 }
197 if paneView != nil {
198 paneView.SetLoading(true)
199 }
200}
201
202// onLoadFinished hides the progress bar when page loading completes.
203func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity) {
204 _, wsView := c.getActiveWS()
205 var paneView *component.PaneView
206 if wsView != nil {
207 paneView = wsView.GetPaneView(paneID)
208 }
209
210 if wsView == nil {
211 return
212 }
213 if paneView != nil {
214 paneView.SetLoading(false)
215 }
216
217 c.revealIfPending(ctx, paneID, wv, identity, "", "load-finished")
218 if c.shouldSkipAboutBlankAppearance(paneID, wv) {
219 return
220 }
221 c.applyPendingThemeUpdate(ctx, paneID, wv)
222 c.refreshPendingScripts(ctx, paneID, wv)
223}
224
225// onProgressChanged updates the progress bar with current load progress.
226func (c *Coordinator) onProgressChanged(paneID entity.PaneID, wv port.WebView, identity webViewIdentity, progress float64) {
227 if progress > 0 {
228 c.revealIfPending(context.Background(), paneID, wv, identity, "", "progress")
229 }
230
231 _, wsView := c.getActiveWS()
232 var paneView *component.PaneView
233 if wsView != nil {
234 paneView = wsView.GetPaneView(paneID)
235 }
236
237 if wsView == nil {
238 return
239 }
240 if paneView != nil {
241 paneView.SetLoadProgress(progress)
242 }
243}
244
245func (c *Coordinator) markPendingReveal(paneID entity.PaneID, identity webViewIdentity) {
246 c.webViewsMu.RLock()
247 c.revealMu.Lock()
248 defer c.revealMu.Unlock()
249 defer c.webViewsMu.RUnlock()
250 currentWebView := c.webViews[paneID]
251 if currentWebView == nil {
252 return
253 }
254 currentIdentity, ok := identityForWebView(currentWebView)
255 if !ok || currentIdentity != identity {
256 return
257 }
258 c.ensureRevealMapsLocked()
259 c.pendingReveal[paneID] = identity
260}
261
262func (c *Coordinator) clearPendingReveal(paneID entity.PaneID, identity webViewIdentity) {
263 c.webViewsMu.RLock()
264 c.revealMu.Lock()
265 defer c.revealMu.Unlock()
266 defer c.webViewsMu.RUnlock()
267 pendingIdentity, pending := c.pendingReveal[paneID]
268 if !pending || pendingIdentity != identity {
269 return
270 }
271 delete(c.pendingReveal, paneID)
272}
273
274// WebViewRevealed reports whether this pane's current WebView has completed a
275// reveal. State is identity-bound so a replacement cannot inherit it.
276func (c *Coordinator) WebViewRevealed(paneID entity.PaneID) bool {
277 return c.webViewRevealed(paneID, nil)
278}
279
280// webViewRevealed verifies the identity being attached when one is supplied.
281// This prevents a rebuild from applying a replacement's reveal state to a
282// previously read pooled WebView.
283func (c *Coordinator) webViewRevealed(paneID entity.PaneID, expected port.WebView) bool {
284 c.webViewsMu.RLock()
285 c.revealMu.Lock()
286 defer c.revealMu.Unlock()
287 defer c.webViewsMu.RUnlock()
288 current := c.webViews[paneID]
289 identity, ok := identityForWebView(current)
290 if !ok || expected != nil {
291 expectedIdentity, expectedOK := identityForWebView(expected)
292 if !expectedOK || expectedIdentity != identity {
293 return false
294 }
295 }
296 revealedIdentity, revealed := c.revealedWebViews[paneID]
297 return revealed && revealedIdentity == identity
298}
299
300func (c *Coordinator) markWebViewRevealed(paneID entity.PaneID, identity webViewIdentity) bool {
301 c.webViewsMu.RLock()
302 c.revealMu.Lock()
303 defer c.revealMu.Unlock()
304 defer c.webViewsMu.RUnlock()
305 currentIdentity, current := identityForWebView(c.webViews[paneID])
306 if !current || currentIdentity != identity {
307 return false
308 }
309 c.ensureRevealMapsLocked()
310 c.revealedWebViews[paneID] = identity
311 return true
312}
313
314func (c *Coordinator) revealIfPending(
315 ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity, uri, reason string,
316) {
317 // Mapping replacement and release acquire this gate too. No ordinary map
318 // lock is held across the native call, but the gate keeps the validated
319 // identity current until its widget visibility has been changed.
320 c.revealMutationMu.Lock()
321
322 c.webViewsMu.RLock()
323 c.revealMu.Lock()
324 pendingIdentity, pending := c.pendingReveal[paneID]
325 if !pending || pendingIdentity != identity {
326 c.revealMu.Unlock()
327 c.webViewsMu.RUnlock()
328 c.revealMutationMu.Unlock()
329 return
330 }
331 currentIdentity, current := identityForWebView(c.webViews[paneID])
332 if !current || currentIdentity != identity {
333 c.revealMu.Unlock()
334 c.webViewsMu.RUnlock()
335 c.revealMutationMu.Unlock()
336 return
337 }
338 delete(c.pendingReveal, paneID)
339 c.revealMu.Unlock()
340 c.webViewsMu.RUnlock()
341
342 if wv.IsDestroyed() {
343 c.revealMutationMu.Unlock()
344 return
345 }
346
347 if c.setWebViewVisible != nil {
348 c.setWebViewVisible(wv)
349 } else if nwp, ok := wv.(port.NativeWidgetProvider); ok {
350 if ptr := nwp.NativeWidget(); ptr != 0 {
351 gtkWidget := >k.Widget{}
352 gtkWidget.Ptr = ptr
353 gtkWidget.SetVisible(true)
354 logging.FromContext(ctx).
355 Debug().
356 Str("pane_id", string(paneID)).
357 Str("uri", uri).
358 Str("reason", reason).
359 Msg("webview revealed")
360 }
361 }
362
363 // Keep the presentation record in the same serialized operation as the
364 // native mutation. User callbacks run only after the gate is released.
365 revealed := c.markWebViewRevealed(paneID, identity)
366 c.revealMutationMu.Unlock()
367 if revealed && c.onWebViewShown != nil {
368 c.onWebViewShown(paneID)
369 }
370}
371
372// onLinkHover updates the link status overlay when hovering over links.
373func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
374 _, wsView := c.getActiveWS()
375 if wsView == nil {
376 return
377 }
378
379 paneView := wsView.GetPaneView(paneID)
380 if paneView == nil {
381 return
382 }
383
384 if uri != "" {
385 paneView.ShowLinkStatus(uri)
386 } else {
387 paneView.HideLinkStatus()
388 }
389}
390
391// externalPrompt is a pane's outstanding external-launch confirmation.
392type externalPrompt struct {
393 token uint64
394 identity webViewIdentity
395}
396
397// promptExternalLaunch asks the user whether the external URI may be handed to
398// the system handler. A pane has at most one outstanding prompt: further
399// external URIs from that pane are dropped until it is answered, so a page
400// cannot flood the window's prompt queue.
401func (c *Coordinator) promptExternalLaunch(ctx context.Context, paneID entity.PaneID, identity webViewIdentity, uri string) {
402 log := logging.FromContext(ctx)
403 if c.onConfirmExternalLaunch == nil || c.onLaunchExternalURL == nil {
404 log.Info().Str("pane_id", string(paneID)).Msg("external scheme launch unavailable; blocked")
405 return
406 }
407
408 c.externalPromptMu.Lock()
409 if _, pending := c.externalPrompts[paneID]; pending {
410 c.externalPromptMu.Unlock()
411 return
412 }
413 if c.externalPrompts == nil {
414 c.externalPrompts = make(map[entity.PaneID]externalPrompt)
415 }
416 c.externalPromptSeq++
417 token := c.externalPromptSeq
418 c.externalPrompts[paneID] = externalPrompt{token: token, identity: identity}
419 origin := c.externalPromptOrigins[paneID]
420 c.externalPromptMu.Unlock()
421
422 c.onConfirmExternalLaunch(paneID, origin, uri, func(allowed bool) {
423 c.externalPromptMu.Lock()
424 prompt, pending := c.externalPrompts[paneID]
425 // The prompt is stale when its pane was released, rebound, or
426 // re-prompted while it was queued: never launch for it.
427 current := pending && prompt.token == token
428 if current {
429 delete(c.externalPrompts, paneID)
430 }
431 c.externalPromptMu.Unlock()
432 current = current && c.isBoundWebView(paneID, identity)
433
434 log.Info().Str("pane_id", string(paneID)).Bool("allowed", allowed).Bool("stale", !current).
435 Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
436 Msg("external scheme launch decision")
437 if allowed && current {
438 c.onLaunchExternalURL(uri)
439 }
440 })
441}
442
443// rememberExternalPromptOrigin records the host of the http(s) document shown
444// in the pane, so a later external-launch prompt can name the requesting site.
445// Any other document clears it: the prompt then says "A page" rather than
446// attributing the request to a previous site.
447func (c *Coordinator) rememberExternalPromptOrigin(paneID entity.PaneID, uri string) {
448 host := ""
449 if parsed, err := neturl.Parse(uri); err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") {
450 host = parsed.Hostname()
451 }
452 c.externalPromptMu.Lock()
453 defer c.externalPromptMu.Unlock()
454 if host == "" {
455 delete(c.externalPromptOrigins, paneID)
456 return
457 }
458 if c.externalPromptOrigins == nil {
459 c.externalPromptOrigins = make(map[entity.PaneID]string)
460 }
461 c.externalPromptOrigins[paneID] = host
462}
463
464// isBoundWebView reports whether identity is still the WebView bound to the
465// pane. Engine callbacks are posted asynchronously and can arrive after the
466// pane was released or rebound.
467func (c *Coordinator) isBoundWebView(paneID entity.PaneID, identity webViewIdentity) bool {
468 bound, ok := identityForWebView(c.GetWebView(paneID))
469 return ok && bound == identity
470}
471
472// clearExternalPrompt invalidates the pane's outstanding external-launch
473// prompt so a late answer cannot launch anything, and forgets its origin.
474func (c *Coordinator) clearExternalPrompt(paneID entity.PaneID) {
475 c.externalPromptMu.Lock()
476 delete(c.externalPrompts, paneID)
477 delete(c.externalPromptOrigins, paneID)
478 c.externalPromptMu.Unlock()
479}
480
481// handleURIChanged handles URI changes from WebKit, including external scheme detection
482// and SPA navigation tracking.
483func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv port.WebView, identity webViewIdentity, uri string) {
484 if uri == "" || !c.isBoundWebView(paneID, identity) {
485 return
486 }
487
488 log := logging.FromContext(ctx)
489
490 // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
491 // These are typically triggered by JavaScript redirects (window.location)
492 isExternal := urlutil.IsExternalScheme(uri)
493
494 if isExternal {
495 log.Info().Str("pane_id", string(paneID)).Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
496 Msg("external scheme URI change intercepted")
497
498 if err := wv.Stop(ctx); err != nil {
499 log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("stop webview for external URL")
500 }
501
502 // URI changes can be driven by page script or redirects (OAuth flows end
503 // with a redirect to claude://, vscode://, ...), so the launch is never
504 // automatic: the user must confirm it. Do not navigate back: going back
505 // to an OAuth endpoint re-issues the redirect and loops.
506 c.promptExternalLaunch(ctx, paneID, identity, uri)
507 return
508 }
509
510 c.rememberExternalPromptOrigin(paneID, uri)
511 if !wv.IsLoading() {
512 c.onSPANavigation(ctx, paneID, uri)
513 }
514}