v0.33.2 57f21d6

download_handler.go RawHistoryFolder

1package cef
2
3import (
4	"context"
5	"fmt"
6	"sync"
7
8	purecef "github.com/bnema/purego-cef/cef"
9
10	"github.com/bnema/dumber/internal/application/port"
11	downloadutil "github.com/bnema/dumber/internal/domain/download"
12	"github.com/bnema/dumber/internal/infrastructure/downloadruntime"
13	"github.com/bnema/dumber/internal/logging"
14)
15
16// maxFinishedEntries caps the finished-ID set. When the cap is reached the set
17// is cleared, accepting a tiny risk of a duplicate terminal event for very old
18// downloads. In practice download IDs are monotonically increasing uint32s and
19// no user session will approach this limit.
20const maxFinishedEntries = 1024
21
22type downloadHandler struct {
23	runtime  *downloadruntime.Runtime
24	mu       sync.Mutex
25	active   map[uint32]cefDownloadState
26	finished map[uint32]struct{} // IDs that already emitted a terminal event
27	// activity tracks download starts against their terminal events for
28	// the quiescence boundary. Nil-safe: unset in unit-test literals.
29	activity *RuntimeActivityTracker
30}
31
32type cefDownloadState struct {
33	filename            string
34	destination         string
35	lastProgressPercent int32
36}
37
38type cefDownloadInterruptError struct {
39	filename string
40	reason   purecef.DownloadInterruptReason
41}
42
43func (e cefDownloadInterruptError) Error() string {
44	return fmt.Sprintf(
45		"download interrupted: %s (code=%d, reason=%s)",
46		e.filename,
47		e.reason,
48		downloadInterruptReasonLabel(e.reason),
49	)
50}
51
52func (e cefDownloadInterruptError) DownloadErrorCode() int {
53	return int(e.reason)
54}
55
56func (e cefDownloadInterruptError) DownloadErrorReason() string {
57	return downloadInterruptReasonLabel(e.reason)
58}
59
60type cefDownloadResponseAdapter struct {
61	item purecef.DownloadItem
62}
63
64var downloadInterruptReasonLabels = map[purecef.DownloadInterruptReason]string{
65	purecef.DownloadInterruptReasonNone:                        "none",
66	purecef.DownloadInterruptReasonFileFailed:                  "file_failed",
67	purecef.DownloadInterruptReasonFileAccessDenied:            "file_access_denied",
68	purecef.DownloadInterruptReasonFileNoSpace:                 "file_no_space",
69	purecef.DownloadInterruptReasonFileNameTooLong:             "file_name_too_long",
70	purecef.DownloadInterruptReasonFileTooLarge:                "file_too_large",
71	purecef.DownloadInterruptReasonFileVirusInfected:           "file_virus_infected",
72	purecef.DownloadInterruptReasonFileTransientError:          "file_transient_error",
73	purecef.DownloadInterruptReasonFileBlocked:                 "file_blocked",
74	purecef.DownloadInterruptReasonFileSecurityCheckFailed:     "file_security_check_failed",
75	purecef.DownloadInterruptReasonFileTooShort:                "file_too_short",
76	purecef.DownloadInterruptReasonFileHashMismatch:            "file_hash_mismatch",
77	purecef.DownloadInterruptReasonFileSameAsSource:            "file_same_as_source",
78	purecef.DownloadInterruptReasonNetworkFailed:               "network_failed",
79	purecef.DownloadInterruptReasonNetworkTimeout:              "network_timeout",
80	purecef.DownloadInterruptReasonNetworkDisconnected:         "network_disconnected",
81	purecef.DownloadInterruptReasonNetworkServerDown:           "network_server_down",
82	purecef.DownloadInterruptReasonNetworkInvalidRequest:       "network_invalid_request",
83	purecef.DownloadInterruptReasonServerFailed:                "server_failed",
84	purecef.DownloadInterruptReasonServerNoRange:               "server_no_range",
85	purecef.DownloadInterruptReasonServerBadContent:            "server_bad_content",
86	purecef.DownloadInterruptReasonServerUnauthorized:          "server_unauthorized",
87	purecef.DownloadInterruptReasonServerCertProblem:           "server_cert_problem",
88	purecef.DownloadInterruptReasonServerForbidden:             "server_forbidden",
89	purecef.DownloadInterruptReasonServerUnreachable:           "server_unreachable",
90	purecef.DownloadInterruptReasonServerContentLengthMismatch: "server_content_length_mismatch",
91	purecef.DownloadInterruptReasonServerCrossOriginRedirect:   "server_cross_origin_redirect",
92	purecef.DownloadInterruptReasonUserCanceled:                "user_canceled",
93	purecef.DownloadInterruptReasonUserShutdown:                "user_shutdown",
94	purecef.DownloadInterruptReasonCrash:                       "crash",
95}
96
97func downloadInterruptReasonLabel(reason purecef.DownloadInterruptReason) string {
98	if label, ok := downloadInterruptReasonLabels[reason]; ok {
99		return label
100	}
101	return "unknown"
102}
103
104func newDownloadHandler(
105	downloadPath string,
106	eventHandler port.DownloadEventHandler,
107	preparer port.DownloadPreparer,
108	activity *RuntimeActivityTracker,
109) *downloadHandler {
110	return &downloadHandler{
111		runtime:  downloadruntime.New(downloadPath, eventHandler, preparer),
112		active:   make(map[uint32]cefDownloadState),
113		finished: make(map[uint32]struct{}),
114		activity: activity,
115	}
116}
117
118func (h *downloadHandler) canDownload(_ purecef.Browser, _, _ string) bool {
119	return true
120}
121
122func (h *downloadHandler) onBeforeDownload(
123	ctx context.Context,
124	browser purecef.Browser,
125	downloadItem purecef.DownloadItem,
126	suggestedName string,
127	callback purecef.BeforeDownloadCallback,
128) bool {
129	if downloadItem == nil || callback == nil {
130		return false
131	}
132	// Retain browser ownership the previous code discarded: activity is
133	// keyed by (browser identity, CEF download ID), never by filename or
134	// destination, which can collide or change mid-download.
135	var browserID int32
136	if browser != nil {
137		browserID = browser.GetIdentifier()
138	}
139	h.activity.NoteDownloadStarted(browserID, downloadItem.GetID())
140
141	log := logging.FromContext(ctx)
142	output, err := h.runtime.ResolveDestination(ctx, suggestedName, &cefDownloadResponseAdapter{item: downloadItem})
143	if err != nil {
144		log.Error().Err(err).Msg("cef: failed to prepare download destination")
145		callback.Cont("", 1)
146		return true
147	}
148
149	h.mu.Lock()
150	h.active[downloadItem.GetID()] = cefDownloadState{
151		filename:            output.Filename,
152		destination:         output.DestinationPath,
153		lastProgressPercent: -1,
154	}
155	h.mu.Unlock()
156
157	callback.Cont(output.DestinationPath, 0)
158	h.runtime.EmitStarted(ctx, output)
159
160	return true
161}
162
163func (h *downloadHandler) onDownloadUpdated(
164	ctx context.Context,
165	browser purecef.Browser,
166	downloadItem purecef.DownloadItem,
167	callback purecef.DownloadItemCallback,
168) {
169	_ = callback
170	if downloadItem == nil {
171		return
172	}
173	var browserID int32
174	if browser != nil {
175		browserID = browser.GetIdentifier()
176	}
177
178	id := downloadItem.GetID()
179
180	// Fast path: skip already-finished downloads to avoid re-populating active.
181	h.mu.Lock()
182	_, done := h.finished[id]
183	h.mu.Unlock()
184	if done {
185		return
186	}
187
188	state := h.currentState(id, downloadItem)
189	h.emitProgressIfNeeded(ctx, id, downloadItem, state)
190
191	switch {
192	case downloadItem.IsComplete():
193		if !h.markFinished(id) {
194			return
195		}
196		h.activity.NoteDownloadTerminal(browserID, id)
197		h.runtime.EmitFinished(ctx, state.filename, state.destination)
198	case downloadItem.IsCanceled() || downloadItem.IsInterrupted():
199		if !h.markFinished(id) {
200			return
201		}
202		h.activity.NoteDownloadTerminal(browserID, id)
203		var err error
204		if downloadItem.IsInterrupted() {
205			err = cefDownloadInterruptError{
206				filename: state.filename,
207				reason:   downloadItem.GetInterruptReason(),
208			}
209		} else {
210			err = fmt.Errorf("download canceled: %s", state.filename)
211		}
212		h.runtime.EmitFailed(ctx, state.filename, state.destination, err)
213	}
214}
215
216func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cefDownloadState {
217	h.mu.Lock()
218	defer h.mu.Unlock()
219
220	state, exists := h.active[id]
221	if !exists {
222		state = cefDownloadState{}
223	}
224	updated := false
225	if state.destination == "" {
226		state.destination = item.GetFullPath()
227		updated = true
228	}
229	if state.filename == "" {
230		state.filename = downloadFilenameFromItem(item, state.destination)
231		updated = true
232	}
233	if exists && updated {
234		h.active[id] = state
235	}
236	return state
237}
238
239func (h *downloadHandler) emitProgressIfNeeded(
240	ctx context.Context,
241	id uint32,
242	item purecef.DownloadItem,
243	state cefDownloadState,
244) {
245	percent, ok := downloadProgressPercentFromItem(item)
246	if !ok {
247		return
248	}
249
250	h.mu.Lock()
251	current, exists := h.active[id]
252	if !exists {
253		current = state
254	}
255	if current.lastProgressPercent == percent {
256		h.mu.Unlock()
257		return
258	}
259	current.lastProgressPercent = percent
260	h.active[id] = current
261	h.mu.Unlock()
262
263	h.runtime.EmitProgress(
264		ctx,
265		current.filename,
266		current.destination,
267		float64(percent)/100,
268		item.GetReceivedBytes(),
269		item.GetTotalBytes(),
270	)
271}
272
273func downloadProgressPercentFromItem(item purecef.DownloadItem) (int32, bool) {
274	if item == nil || item.IsComplete() || item.IsCanceled() || item.IsInterrupted() {
275		return 0, false
276	}
277
278	percent := item.GetPercentComplete()
279	if percent < 0 {
280		total := item.GetTotalBytes()
281		received := item.GetReceivedBytes()
282		if total <= 0 || received <= 0 {
283			return 0, false
284		}
285		percent = int32((received * 100) / total)
286	}
287	if percent <= 0 || percent >= 100 {
288		return 0, false
289	}
290	return percent, true
291}
292
293func (h *downloadHandler) markFinished(id uint32) bool {
294	h.mu.Lock()
295	defer h.mu.Unlock()
296
297	delete(h.active, id)
298	if _, seen := h.finished[id]; seen {
299		return false
300	}
301	if len(h.finished) >= maxFinishedEntries {
302		clear(h.finished)
303	}
304	h.finished[id] = struct{}{}
305	return true
306}
307
308func downloadFilenameFromItem(item purecef.DownloadItem, destination string) string {
309	if destination != "" {
310		return downloadutil.ExtractFilenameFromDestination(destination)
311	}
312	if item == nil {
313		return downloadutil.DefaultFilename
314	}
315	if suggested := item.GetSuggestedFileName(); suggested != "" {
316		return downloadutil.SanitizeFilenameWithExtension(suggested, item.GetMimeType())
317	}
318	if uri := item.GetURL(); uri != "" {
319		return downloadutil.ExtractFilenameFromURI(uri)
320	}
321	return downloadutil.DefaultFilename
322}
323
324func (a *cefDownloadResponseAdapter) GetMimeType() string {
325	if a == nil || a.item == nil {
326		return ""
327	}
328	return a.item.GetMimeType()
329}
330
331func (a *cefDownloadResponseAdapter) GetSuggestedFilename() string {
332	if a == nil || a.item == nil {
333		return ""
334	}
335	return a.item.GetSuggestedFileName()
336}
337
338func (a *cefDownloadResponseAdapter) GetUri() string {
339	if a == nil || a.item == nil {
340		return ""
341	}
342	return a.item.GetURL()
343}

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