v0.31.0 bf0756b

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}
28
29type cefDownloadState struct {
30	filename            string
31	destination         string
32	lastProgressPercent int32
33}
34
35type cefDownloadInterruptError struct {
36	filename string
37	reason   purecef.DownloadInterruptReason
38}
39
40func (e cefDownloadInterruptError) Error() string {
41	return fmt.Sprintf(
42		"download interrupted: %s (code=%d, reason=%s)",
43		e.filename,
44		e.reason,
45		downloadInterruptReasonLabel(e.reason),
46	)
47}
48
49func (e cefDownloadInterruptError) DownloadErrorCode() int {
50	return int(e.reason)
51}
52
53func (e cefDownloadInterruptError) DownloadErrorReason() string {
54	return downloadInterruptReasonLabel(e.reason)
55}
56
57type cefDownloadResponseAdapter struct {
58	item purecef.DownloadItem
59}
60
61var downloadInterruptReasonLabels = map[purecef.DownloadInterruptReason]string{
62	purecef.DownloadInterruptReasonNone:                        "none",
63	purecef.DownloadInterruptReasonFileFailed:                  "file_failed",
64	purecef.DownloadInterruptReasonFileAccessDenied:            "file_access_denied",
65	purecef.DownloadInterruptReasonFileNoSpace:                 "file_no_space",
66	purecef.DownloadInterruptReasonFileNameTooLong:             "file_name_too_long",
67	purecef.DownloadInterruptReasonFileTooLarge:                "file_too_large",
68	purecef.DownloadInterruptReasonFileVirusInfected:           "file_virus_infected",
69	purecef.DownloadInterruptReasonFileTransientError:          "file_transient_error",
70	purecef.DownloadInterruptReasonFileBlocked:                 "file_blocked",
71	purecef.DownloadInterruptReasonFileSecurityCheckFailed:     "file_security_check_failed",
72	purecef.DownloadInterruptReasonFileTooShort:                "file_too_short",
73	purecef.DownloadInterruptReasonFileHashMismatch:            "file_hash_mismatch",
74	purecef.DownloadInterruptReasonFileSameAsSource:            "file_same_as_source",
75	purecef.DownloadInterruptReasonNetworkFailed:               "network_failed",
76	purecef.DownloadInterruptReasonNetworkTimeout:              "network_timeout",
77	purecef.DownloadInterruptReasonNetworkDisconnected:         "network_disconnected",
78	purecef.DownloadInterruptReasonNetworkServerDown:           "network_server_down",
79	purecef.DownloadInterruptReasonNetworkInvalidRequest:       "network_invalid_request",
80	purecef.DownloadInterruptReasonServerFailed:                "server_failed",
81	purecef.DownloadInterruptReasonServerNoRange:               "server_no_range",
82	purecef.DownloadInterruptReasonServerBadContent:            "server_bad_content",
83	purecef.DownloadInterruptReasonServerUnauthorized:          "server_unauthorized",
84	purecef.DownloadInterruptReasonServerCertProblem:           "server_cert_problem",
85	purecef.DownloadInterruptReasonServerForbidden:             "server_forbidden",
86	purecef.DownloadInterruptReasonServerUnreachable:           "server_unreachable",
87	purecef.DownloadInterruptReasonServerContentLengthMismatch: "server_content_length_mismatch",
88	purecef.DownloadInterruptReasonServerCrossOriginRedirect:   "server_cross_origin_redirect",
89	purecef.DownloadInterruptReasonUserCanceled:                "user_canceled",
90	purecef.DownloadInterruptReasonUserShutdown:                "user_shutdown",
91	purecef.DownloadInterruptReasonCrash:                       "crash",
92}
93
94func downloadInterruptReasonLabel(reason purecef.DownloadInterruptReason) string {
95	if label, ok := downloadInterruptReasonLabels[reason]; ok {
96		return label
97	}
98	return "unknown"
99}
100
101func newDownloadHandler(
102	downloadPath string,
103	eventHandler port.DownloadEventHandler,
104	preparer port.DownloadPreparer,
105) *downloadHandler {
106	return &downloadHandler{
107		runtime:  downloadruntime.New(downloadPath, eventHandler, preparer),
108		active:   make(map[uint32]cefDownloadState),
109		finished: make(map[uint32]struct{}),
110	}
111}
112
113func (h *downloadHandler) canDownload(_ purecef.Browser, _, _ string) bool {
114	return true
115}
116
117func (h *downloadHandler) onBeforeDownload(
118	ctx context.Context,
119	_ purecef.Browser,
120	downloadItem purecef.DownloadItem,
121	suggestedName string,
122	callback purecef.BeforeDownloadCallback,
123) bool {
124	if downloadItem == nil || callback == nil {
125		return false
126	}
127
128	log := logging.FromContext(ctx)
129	output, err := h.runtime.ResolveDestination(ctx, suggestedName, &cefDownloadResponseAdapter{item: downloadItem})
130	if err != nil {
131		log.Error().Err(err).Msg("cef: failed to prepare download destination")
132		callback.Cont("", 1)
133		return true
134	}
135
136	h.mu.Lock()
137	h.active[downloadItem.GetID()] = cefDownloadState{
138		filename:            output.Filename,
139		destination:         output.DestinationPath,
140		lastProgressPercent: -1,
141	}
142	h.mu.Unlock()
143
144	callback.Cont(output.DestinationPath, 0)
145	h.runtime.EmitStarted(ctx, output)
146
147	return true
148}
149
150func (h *downloadHandler) onDownloadUpdated(
151	ctx context.Context,
152	downloadItem purecef.DownloadItem,
153	callback purecef.DownloadItemCallback,
154) {
155	_ = callback
156	if downloadItem == nil {
157		return
158	}
159
160	id := downloadItem.GetID()
161
162	// Fast path: skip already-finished downloads to avoid re-populating active.
163	h.mu.Lock()
164	_, done := h.finished[id]
165	h.mu.Unlock()
166	if done {
167		return
168	}
169
170	state := h.currentState(id, downloadItem)
171	h.emitProgressIfNeeded(ctx, id, downloadItem, state)
172
173	switch {
174	case downloadItem.IsComplete():
175		if !h.markFinished(id) {
176			return
177		}
178		h.runtime.EmitFinished(ctx, state.filename, state.destination)
179	case downloadItem.IsCanceled() || downloadItem.IsInterrupted():
180		if !h.markFinished(id) {
181			return
182		}
183		var err error
184		if downloadItem.IsInterrupted() {
185			err = cefDownloadInterruptError{
186				filename: state.filename,
187				reason:   downloadItem.GetInterruptReason(),
188			}
189		} else {
190			err = fmt.Errorf("download canceled: %s", state.filename)
191		}
192		h.runtime.EmitFailed(ctx, state.filename, state.destination, err)
193	}
194}
195
196func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cefDownloadState {
197	h.mu.Lock()
198	defer h.mu.Unlock()
199
200	state, exists := h.active[id]
201	if !exists {
202		state = cefDownloadState{}
203	}
204	updated := false
205	if state.destination == "" {
206		state.destination = item.GetFullPath()
207		updated = true
208	}
209	if state.filename == "" {
210		state.filename = downloadFilenameFromItem(item, state.destination)
211		updated = true
212	}
213	if exists && updated {
214		h.active[id] = state
215	}
216	return state
217}
218
219func (h *downloadHandler) emitProgressIfNeeded(
220	ctx context.Context,
221	id uint32,
222	item purecef.DownloadItem,
223	state cefDownloadState,
224) {
225	percent, ok := downloadProgressPercentFromItem(item)
226	if !ok {
227		return
228	}
229
230	h.mu.Lock()
231	current, exists := h.active[id]
232	if !exists {
233		current = state
234	}
235	if current.lastProgressPercent == percent {
236		h.mu.Unlock()
237		return
238	}
239	current.lastProgressPercent = percent
240	h.active[id] = current
241	h.mu.Unlock()
242
243	h.runtime.EmitProgress(
244		ctx,
245		current.filename,
246		current.destination,
247		float64(percent)/100,
248		item.GetReceivedBytes(),
249		item.GetTotalBytes(),
250	)
251}
252
253func downloadProgressPercentFromItem(item purecef.DownloadItem) (int32, bool) {
254	if item == nil || item.IsComplete() || item.IsCanceled() || item.IsInterrupted() {
255		return 0, false
256	}
257
258	percent := item.GetPercentComplete()
259	if percent < 0 {
260		total := item.GetTotalBytes()
261		received := item.GetReceivedBytes()
262		if total <= 0 || received <= 0 {
263			return 0, false
264		}
265		percent = int32((received * 100) / total)
266	}
267	if percent <= 0 || percent >= 100 {
268		return 0, false
269	}
270	return percent, true
271}
272
273func (h *downloadHandler) markFinished(id uint32) bool {
274	h.mu.Lock()
275	defer h.mu.Unlock()
276
277	delete(h.active, id)
278	if _, seen := h.finished[id]; seen {
279		return false
280	}
281	if len(h.finished) >= maxFinishedEntries {
282		clear(h.finished)
283	}
284	h.finished[id] = struct{}{}
285	return true
286}
287
288func downloadFilenameFromItem(item purecef.DownloadItem, destination string) string {
289	if destination != "" {
290		return downloadutil.ExtractFilenameFromDestination(destination)
291	}
292	if item == nil {
293		return downloadutil.DefaultFilename
294	}
295	if suggested := item.GetSuggestedFileName(); suggested != "" {
296		return downloadutil.SanitizeFilenameWithExtension(suggested, item.GetMimeType())
297	}
298	if uri := item.GetURL(); uri != "" {
299		return downloadutil.ExtractFilenameFromURI(uri)
300	}
301	return downloadutil.DefaultFilename
302}
303
304func (a *cefDownloadResponseAdapter) GetMimeType() string {
305	if a == nil || a.item == nil {
306		return ""
307	}
308	return a.item.GetMimeType()
309}
310
311func (a *cefDownloadResponseAdapter) GetSuggestedFilename() string {
312	if a == nil || a.item == nil {
313		return ""
314	}
315	return a.item.GetSuggestedFileName()
316}
317
318func (a *cefDownloadResponseAdapter) GetUri() string {
319	if a == nil || a.item == nil {
320		return ""
321	}
322	return a.item.GetURL()
323}

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