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