v0.30.0 eb3068f

callbacks.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close