1package content
2
3import (
4 "context"
5 "net/url"
6 "strings"
7
8 "github.com/bnema/dumber/internal/application/dto"
9 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/bnema/dumber/internal/domain/entity"
12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/dumber/internal/ui/component"
14)
15
16// internalSchemePath is the host used in dumb:// crash-page URIs.
17const internalSchemePath = "history"
18
19func shouldRenderCrashPage(reason port.WebProcessTerminationReason) bool {
20 switch reason {
21 case port.WebProcessTerminationCrashed, port.WebProcessTerminationExceededMemory:
22 return true
23 case port.WebProcessTerminationByAPI:
24 return false
25 default:
26 return true
27 }
28}
29
30func extractOriginalURIFromCrashPage(uri string) string {
31 if uri == "" {
32 return ""
33 }
34
35 parsed, err := url.Parse(uri)
36 if err != nil {
37 return uri
38 }
39
40 if parsed.Scheme != "dumb" || parsed.Host != internalSchemePath {
41 return uri
42 }
43
44 if strings.Trim(parsed.Path, "/") != "crash" {
45 return uri
46 }
47
48 original := strings.TrimSpace(parsed.Query().Get("url"))
49 if original == "" {
50 return ""
51 }
52 return original
53}
54
55func buildCrashPageURI(originalURI string) string {
56 if strings.TrimSpace(originalURI) == "" {
57 return crashPageURI
58 }
59 query := url.Values{}
60 query.Set("url", originalURI)
61 return crashPageURI + "?" + query.Encode()
62}
63
64// setupWebViewCallbacks configures standard callbacks and popup handling.
65func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
66 log := logging.FromContext(ctx)
67 identity, ok := identityForWebView(wv)
68 if !ok {
69 return
70 }
71
72 // Capture the association at installation, rather than reading mutable
73 // WebView identity from a callback that can outlive a pool reuse.
74 callbacks := &port.WebViewCallbacks{
75 OnTitleChanged: func(title string) {
76 c.onTitleChanged(ctx, paneID, title)
77 },
78 OnLoadChanged: func(event port.LoadEvent) {
79 switch event {
80 case port.LoadStarted:
81 c.onLoadStarted(paneID)
82 case port.LoadCommitted:
83 c.onLoadCommitted(ctx, paneID, wv, identity)
84 case port.LoadFinished:
85 c.onLoadFinished(ctx, paneID, wv, identity)
86 }
87 },
88 OnProgressChanged: func(progress float64) {
89 c.onProgressChanged(paneID, wv, identity, progress)
90 },
91 OnURIChanged: func(uri string) {
92 c.handleURIChanged(ctx, paneID, wv, uri)
93 },
94 OnLinkHover: func(uri string) {
95 c.onLinkHover(paneID, uri)
96 },
97 OnTouchpadNavigationGesture: func(gesture entity.TouchpadNavigationGesture) {
98 if c.onTouchpadNavigationGesture != nil {
99 c.onTouchpadNavigationGesture(paneID, gesture)
100 }
101 },
102 OnWebProcessTerminated: func(reason port.WebProcessTerminationReason, reasonLabel string, uri string) {
103 originalURI := extractOriginalURIFromCrashPage(uri)
104 if !shouldRenderCrashPage(reason) {
105 log.Info().
106 Str("pane_id", string(paneID)).
107 Str("reason", reasonLabel).
108 Str("uri", uri).
109 Msg("web process termination handled without crash page")
110 return
111 }
112
113 crashURI := buildCrashPageURI(originalURI)
114 log.Warn().
115 Str("pane_id", string(paneID)).
116 Str("reason", reasonLabel).
117 Str("uri", uri).
118 Str("crash_uri", crashURI).
119 Msg("web process terminated, redirecting to crash page")
120
121 if err := wv.LoadURI(ctx, crashURI); err != nil {
122 log.Error().
123 Err(err).
124 Str("pane_id", string(paneID)).
125 Str("reason", reasonLabel).
126 Str("uri", uri).
127 Str("crash_uri", crashURI).
128 Msg("failed to load crash page after web process termination")
129 }
130 },
131 OnPermissionRequest: func(origin string, permTypes []string, metadata map[string]string, allow, deny func()) bool {
132 return c.handlePermissionRequest(ctx, paneID, origin, permTypes, metadata, allow, deny)
133 },
134 }
135
136 c.setupJSDialogCallbacks(paneID, callbacks)
137 c.setupFaviconCallbacks(ctx, paneID, wv, callbacks)
138
139 // Middle-click link handler
140 callbacks.OnLinkMiddleClick = func(uri string) bool {
141 return c.handleLinkMiddleClick(ctx, paneID, uri)
142 }
143
144 // Fullscreen handlers for idle inhibition
145 callbacks.OnEnterFullscreen = func() bool {
146 if c.idleInhibitor != nil {
147 if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
148 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
149 }
150 }
151 if c.onFullscreenChanged != nil {
152 c.onFullscreenChanged(paneID, true)
153 }
154 return false // Allow fullscreen
155 }
156
157 callbacks.OnLeaveFullscreen = func() bool {
158 if c.idleInhibitor != nil {
159 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
160 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
161 }
162 }
163 if c.onFullscreenChanged != nil {
164 c.onFullscreenChanged(paneID, false)
165 }
166 return false // Allow leaving fullscreen
167 }
168
169 // Audio playback handling
170 callbacks.OnAudioStateChanged = func(playing bool) {
171 if c.idleInhibitor == nil {
172 return
173 }
174 if playing {
175 if err := c.idleInhibitor.Inhibit(ctx, "Media playback"); err != nil {
176 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle for audio")
177 }
178 } else {
179 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
180 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle for audio")
181 }
182 }
183 }
184
185 callbacks.OnEditableFocusChanged = func(editable bool) {
186 if c.onEditableFocusChanged != nil {
187 c.onEditableFocusChanged(paneID, editable)
188 }
189 }
190
191 c.setupVimPageCallbacks(paneID, callbacks)
192
193 // Add popup create handler if popup handling is configured
194 callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)
195
196 wv.SetCallbacks(callbacks)
197}
198
199// setupVimPageCallbacks forwards in-page Vim interaction events for one pane.
200func (c *Coordinator) setupVimPageCallbacks(paneID entity.PaneID, callbacks *port.WebViewCallbacks) {
201 callbacks.OnVimPageInteractionEnded = func() {
202 if c.onVimPageInteractionEnded != nil {
203 c.onVimPageInteractionEnded(paneID)
204 }
205 }
206 callbacks.OnVimPageModeChanged = func(mode dto.VimPageMode) {
207 if c.onVimPageModeChanged != nil {
208 c.onVimPageModeChanged(paneID, mode)
209 }
210 }
211}
212
213// handlePermissionRequest processes media permission requests from WebKit.
214// It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
215func (c *Coordinator) handlePermissionRequest(
216 ctx context.Context,
217 paneID entity.PaneID,
218 origin string,
219 permTypes []string,
220 metadata map[string]string,
221 allow, deny func(),
222) bool {
223 log := logging.FromContext(ctx)
224
225 entityTypes := permissionTypesFromStrings(ctx, permTypes)
226
227 if len(entityTypes) == 0 {
228 log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
229 deny()
230 return true
231 }
232
233 trackedTypes := filterWebRTCPermissionTypes(entityTypes)
234 notifyActivity := func(state PermissionActivityState) {
235 if c.onPermissionActivity == nil || len(trackedTypes) == 0 {
236 return
237 }
238 c.onPermissionActivity(paneID, origin, trackedTypes, state)
239 }
240
241 notifyActivity(PermissionActivityRequesting)
242
243 wrappedAllow := func() {
244 notifyActivity(PermissionActivityAllowed)
245 allow()
246 }
247 wrappedDeny := func() {
248 notifyActivity(PermissionActivityBlocked)
249 deny()
250 }
251
252 // Check if permission use case is available
253 if c.permissionUC == nil {
254 log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
255 // Auto-allow display and device_info, deny others
256 allAutoAllow := true
257 for _, pt := range entityTypes {
258 if !entity.IsAutoAllowFor(pt, entity.PermissionMetadata(metadata)) {
259 allAutoAllow = false
260 break
261 }
262 }
263 if allAutoAllow {
264 wrappedAllow()
265 } else {
266 wrappedDeny()
267 }
268 return true
269 }
270
271 // Bind the dialog presenter of the window owning this pane before handling.
272 if c.onPermissionPrompt != nil {
273 c.onPermissionPrompt(paneID)
274 }
275
276 // Delegate to use case
277 callback := usecase.PermissionCallback{
278 Allow: wrappedAllow,
279 Deny: wrappedDeny,
280 }
281
282 c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, entity.PermissionMetadata(metadata), callback)
283 return true
284}
285
286// permissionTypesFromStrings converts engine permission type strings to entity
287// types, skipping unknown ones.
288func permissionTypesFromStrings(ctx context.Context, permTypes []string) []entity.PermissionType {
289 log := logging.FromContext(ctx)
290 entityTypes := make([]entity.PermissionType, 0, len(permTypes))
291 for _, pt := range permTypes {
292 switch pt {
293 case "microphone":
294 entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
295 case "camera":
296 entityTypes = append(entityTypes, entity.PermissionTypeCamera)
297 case "display":
298 entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
299 case "notification":
300 entityTypes = append(entityTypes, entity.PermissionTypeNotification)
301 case "geolocation":
302 entityTypes = append(entityTypes, entity.PermissionTypeGeolocation)
303 case "device_info":
304 entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
305 case "website_data_access":
306 entityTypes = append(entityTypes, entity.PermissionTypeWebsiteDataAccess)
307 default:
308 log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
309 }
310 }
311 return entityTypes
312}
313
314func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {
315 filtered := make([]entity.PermissionType, 0, len(types))
316 for _, permType := range types {
317 switch permType {
318 case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
319 filtered = append(filtered, permType)
320 }
321 }
322 return filtered
323}
324
325// setupJSDialogCallbacks routes JavaScript dialogs to the owning pane's overlay.
326func (c *Coordinator) setupJSDialogCallbacks(paneID entity.PaneID, callbacks *port.WebViewCallbacks) {
327 callbacks.OnJSDialog = func(req port.JSDialogRequest, respond func(ok bool, input string)) bool {
328 pv := c.paneViewForJSDialog(paneID)
329 if pv == nil {
330 return false
331 }
332 return pv.ShowJSDialog(req, respond, c.jsDialogUIScale())
333 }
334 callbacks.OnJSDialogReset = func() {
335 if pv := c.paneViewForJSDialog(paneID); pv != nil {
336 pv.HideJSDialog()
337 }
338 }
339}
340
341// jsDialogUIScale returns the current UI scale (1 when no provider is set).
342func (c *Coordinator) jsDialogUIScale() float64 {
343 if c.uiScaleProvider == nil {
344 return 1
345 }
346 return c.uiScaleProvider()
347}
348
349// paneViewForJSDialog resolves the PaneView that owns paneID's WebView through
350// the app-provided resolver (any tab/window). The dialog is shown inside that
351// pane's overlay even if the pane is in a background tab and never blocks others.
352func (c *Coordinator) paneViewForJSDialog(paneID entity.PaneID) *component.PaneView {
353 if c.paneViewResolver == nil {
354 return nil
355 }
356 return c.paneViewResolver(paneID)
357}