v0.32.0 0617278

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	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}

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