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
66 // Build port callbacks for standard events
67 callbacks := &port.WebViewCallbacks{
68 OnTitleChanged: func(title string) {
69 c.onTitleChanged(ctx, paneID, title)
70 },
71 OnLoadChanged: func(event port.LoadEvent) {
72 switch event {
73 case port.LoadStarted:
74 c.onLoadStarted(paneID)
75 case port.LoadCommitted:
76 c.onLoadCommitted(ctx, paneID, wv)
77 case port.LoadFinished:
78 c.onLoadFinished(ctx, paneID, wv)
79 }
80 },
81 OnProgressChanged: func(progress float64) {
82 c.onProgressChanged(paneID, progress)
83 },
84 OnURIChanged: func(uri string) {
85 c.handleURIChanged(ctx, paneID, wv, uri)
86 },
87 OnLinkHover: func(uri string) {
88 c.onLinkHover(paneID, uri)
89 },
90 OnTouchpadNavigationGesture: func(gesture entity.TouchpadNavigationGesture) {
91 if c.onTouchpadNavigationGesture != nil {
92 c.onTouchpadNavigationGesture(paneID, gesture)
93 }
94 },
95 OnWebProcessTerminated: func(reason port.WebProcessTerminationReason, reasonLabel string, uri string) {
96 originalURI := extractOriginalURIFromCrashPage(uri)
97 if !shouldRenderCrashPage(reason) {
98 log.Info().
99 Str("pane_id", string(paneID)).
100 Str("reason", reasonLabel).
101 Str("uri", uri).
102 Msg("web process termination handled without crash page")
103 return
104 }
105
106 crashURI := buildCrashPageURI(originalURI)
107 log.Warn().
108 Str("pane_id", string(paneID)).
109 Str("reason", reasonLabel).
110 Str("uri", uri).
111 Str("crash_uri", crashURI).
112 Msg("web process terminated, redirecting to crash page")
113
114 if err := wv.LoadURI(ctx, crashURI); err != nil {
115 log.Error().
116 Err(err).
117 Str("pane_id", string(paneID)).
118 Str("reason", reasonLabel).
119 Str("uri", uri).
120 Str("crash_uri", crashURI).
121 Msg("failed to load crash page after web process termination")
122 }
123 },
124 OnPermissionRequest: func(origin string, permTypes []string, metadata map[string]string, allow, deny func()) bool {
125 return c.handlePermissionRequest(ctx, paneID, origin, permTypes, metadata, allow, deny)
126 },
127 }
128
129 c.setupFaviconCallbacks(ctx, paneID, wv, callbacks)
130
131 // Middle-click link handler
132 callbacks.OnLinkMiddleClick = func(uri string) bool {
133 return c.handleLinkMiddleClick(ctx, paneID, uri)
134 }
135
136 // Fullscreen handlers for idle inhibition
137 callbacks.OnEnterFullscreen = func() bool {
138 if c.idleInhibitor != nil {
139 if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
140 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
141 }
142 }
143 if c.onFullscreenChanged != nil {
144 c.onFullscreenChanged(paneID, true)
145 }
146 return false // Allow fullscreen
147 }
148
149 callbacks.OnLeaveFullscreen = func() bool {
150 if c.idleInhibitor != nil {
151 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
152 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
153 }
154 }
155 if c.onFullscreenChanged != nil {
156 c.onFullscreenChanged(paneID, false)
157 }
158 return false // Allow leaving fullscreen
159 }
160
161 // Audio playback handling
162 callbacks.OnAudioStateChanged = func(playing bool) {
163 if c.idleInhibitor == nil {
164 return
165 }
166 if playing {
167 if err := c.idleInhibitor.Inhibit(ctx, "Media playback"); err != nil {
168 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle for audio")
169 }
170 } else {
171 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
172 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle for audio")
173 }
174 }
175 }
176
177 // Add popup create handler if popup handling is configured
178 callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)
179
180 wv.SetCallbacks(callbacks)
181}
182
183// handlePermissionRequest processes media permission requests from WebKit.
184// It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
185func (c *Coordinator) handlePermissionRequest(
186 ctx context.Context,
187 paneID entity.PaneID,
188 origin string,
189 permTypes []string,
190 metadata map[string]string,
191 allow, deny func(),
192) bool {
193 log := logging.FromContext(ctx)
194
195 // Convert string permission types to entity types
196 entityTypes := make([]entity.PermissionType, 0, len(permTypes))
197 for _, pt := range permTypes {
198 switch pt {
199 case "microphone":
200 entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
201 case "camera":
202 entityTypes = append(entityTypes, entity.PermissionTypeCamera)
203 case "display":
204 entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
205 case "device_info":
206 entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
207 case "website_data_access":
208 entityTypes = append(entityTypes, entity.PermissionTypeWebsiteDataAccess)
209 default:
210 log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
211 }
212 }
213
214 if len(entityTypes) == 0 {
215 log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
216 deny()
217 return true
218 }
219
220 trackedTypes := filterWebRTCPermissionTypes(entityTypes)
221 notifyActivity := func(state PermissionActivityState) {
222 if c.onPermissionActivity == nil || len(trackedTypes) == 0 {
223 return
224 }
225 c.onPermissionActivity(paneID, origin, trackedTypes, state)
226 }
227
228 notifyActivity(PermissionActivityRequesting)
229
230 wrappedAllow := func() {
231 notifyActivity(PermissionActivityAllowed)
232 allow()
233 }
234 wrappedDeny := func() {
235 notifyActivity(PermissionActivityBlocked)
236 deny()
237 }
238
239 // Check if permission use case is available
240 if c.permissionUC == nil {
241 log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
242 // Auto-allow display and device_info, deny others
243 allAutoAllow := true
244 for _, pt := range entityTypes {
245 if !entity.IsAutoAllow(pt) {
246 allAutoAllow = false
247 break
248 }
249 }
250 if allAutoAllow {
251 wrappedAllow()
252 } else {
253 wrappedDeny()
254 }
255 return true
256 }
257
258 // Delegate to use case
259 callback := usecase.PermissionCallback{
260 Allow: wrappedAllow,
261 Deny: wrappedDeny,
262 }
263
264 c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, entity.PermissionMetadata(metadata), callback)
265 return true
266}
267
268func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {
269 filtered := make([]entity.PermissionType, 0, len(types))
270 for _, permType := range types {
271 switch permType {
272 case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
273 filtered = append(filtered, permType)
274 }
275 }
276 return filtered
277}