v0.28.0 3cb3770

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

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