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/puregotk/v4/gdk"
13)
14
15// internalSchemePath is the path used in dumb:// URIs (replaces webkit.HomePath).
16const internalSchemePath = "home"
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
67 // Build port callbacks for standard events
68 callbacks := &port.WebViewCallbacks{
69 OnTitleChanged: func(title string) {
70 c.onTitleChanged(ctx, paneID, title)
71 },
72 OnLoadChanged: func(event port.LoadEvent) {
73 switch event {
74 case port.LoadStarted:
75 c.onLoadStarted(paneID)
76 case port.LoadCommitted:
77 c.onLoadCommitted(ctx, paneID, wv)
78 case port.LoadFinished:
79 c.onLoadFinished(ctx, paneID, wv)
80 }
81 },
82 OnProgressChanged: func(progress float64) {
83 c.onProgressChanged(paneID, progress)
84 },
85 OnURIChanged: func(uri string) {
86 c.handleURIChanged(ctx, paneID, wv, uri)
87 },
88 OnLinkHover: func(uri string) {
89 c.onLinkHover(paneID, uri)
90 },
91 OnWebProcessTerminated: func(reason port.WebProcessTerminationReason, reasonLabel string, uri string) {
92 originalURI := extractOriginalURIFromCrashPage(uri)
93 if !shouldRenderCrashPage(reason) {
94 log.Info().
95 Str("pane_id", string(paneID)).
96 Str("reason", reasonLabel).
97 Str("uri", uri).
98 Msg("web process termination handled without crash page")
99 return
100 }
101
102 crashURI := buildCrashPageURI(originalURI)
103 log.Warn().
104 Str("pane_id", string(paneID)).
105 Str("reason", reasonLabel).
106 Str("uri", uri).
107 Str("crash_uri", crashURI).
108 Msg("web process terminated, redirecting to crash page")
109
110 if err := wv.LoadURI(ctx, crashURI); err != nil {
111 log.Error().
112 Err(err).
113 Str("pane_id", string(paneID)).
114 Str("reason", reasonLabel).
115 Str("uri", uri).
116 Str("crash_uri", crashURI).
117 Msg("failed to load crash page after web process termination")
118 }
119 },
120 OnPermissionRequest: func(origin string, permTypes []string, metadata map[string]string, allow, deny func()) bool {
121 return c.handlePermissionRequest(ctx, origin, permTypes, metadata, allow, deny)
122 },
123 }
124
125 // Favicon changes - use Generation() from port.WebView to detect stale callbacks
126 faviconGen := wv.Generation()
127 callbacks.OnFaviconChanged = func(favicon port.Texture) {
128 if wv.Generation() != faviconGen {
129 return // WebView was reused, ignore stale signal
130 }
131 if gdkTexture, ok := favicon.(*gdk.Texture); ok {
132 c.onFaviconChanged(ctx, paneID, wv, gdkTexture)
133 }
134 }
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(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(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 // Add popup create handler if popup handling is configured
183 callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)
184
185 wv.SetCallbacks(callbacks)
186}
187
188// handlePermissionRequest processes media permission requests from WebKit.
189// It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
190func (c *Coordinator) handlePermissionRequest(
191 ctx context.Context,
192 origin string,
193 permTypes []string,
194 metadata map[string]string,
195 allow, deny func(),
196) bool {
197 log := logging.FromContext(ctx)
198
199 // Convert string permission types to entity types
200 entityTypes := make([]entity.PermissionType, 0, len(permTypes))
201 for _, pt := range permTypes {
202 switch pt {
203 case "microphone":
204 entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
205 case "camera":
206 entityTypes = append(entityTypes, entity.PermissionTypeCamera)
207 case "display":
208 entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
209 case "device_info":
210 entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
211 case "website_data_access":
212 entityTypes = append(entityTypes, entity.PermissionTypeWebsiteDataAccess)
213 default:
214 log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
215 }
216 }
217
218 if len(entityTypes) == 0 {
219 log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
220 deny()
221 return true
222 }
223
224 trackedTypes := filterWebRTCPermissionTypes(entityTypes)
225 notifyActivity := func(state PermissionActivityState) {
226 if c.onPermissionActivity == nil || len(trackedTypes) == 0 {
227 return
228 }
229 c.onPermissionActivity(origin, trackedTypes, state)
230 }
231
232 notifyActivity(PermissionActivityRequesting)
233
234 wrappedAllow := func() {
235 notifyActivity(PermissionActivityAllowed)
236 allow()
237 }
238 wrappedDeny := func() {
239 notifyActivity(PermissionActivityBlocked)
240 deny()
241 }
242
243 // Check if permission use case is available
244 if c.permissionUC == nil {
245 log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
246 // Auto-allow display and device_info, deny others
247 allAutoAllow := true
248 for _, pt := range entityTypes {
249 if !entity.IsAutoAllow(pt) {
250 allAutoAllow = false
251 break
252 }
253 }
254 if allAutoAllow {
255 wrappedAllow()
256 } else {
257 wrappedDeny()
258 }
259 return true
260 }
261
262 // Delegate to use case
263 callback := usecase.PermissionCallback{
264 Allow: wrappedAllow,
265 Deny: wrappedDeny,
266 }
267
268 c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, entity.PermissionMetadata(metadata), callback)
269 return true
270}
271
272func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {
273 filtered := make([]entity.PermissionType, 0, len(types))
274 for _, permType := range types {
275 switch permType {
276 case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
277 filtered = append(filtered, permType)
278 }
279 }
280 return filtered
281}