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