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}