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}