detached cf77049

Merge pull request #227 from bnema/feat/shared-download-handling-cef-webkit

feat(download): add shared download handling for CEF and WebKit
commit
cf770499f7e24a3ac4cd6476a135580d0b7c36f2
author
bnema · · 2026-04-20 06:10 UTC
committer
GitHub · · 2026-04-20 06:10 UTC
merge of
70cc912 + 44cfca4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
18 files · +1427 -150
M internal/application/port/download.go +9 -4
@@ -8,6 +8,8 @@ type DownloadEventType int
88 const (
99 // DownloadEventStarted indicates a download has begun.
1010 DownloadEventStarted DownloadEventType = iota
11+ // DownloadEventProgress indicates download progress has advanced.
12+ DownloadEventProgress
1113 // DownloadEventFinished indicates a download completed successfully.
1214 DownloadEventFinished
1315 // DownloadEventFailed indicates a download failed.
@@ -16,10 +18,13 @@ const (
1618
1719 // DownloadEvent contains information about a download event.
1820 type DownloadEvent struct {
19- Type DownloadEventType
20- Filename string
21- Destination string
22- Error error // Set when Type is DownloadEventFailed
21+ Type DownloadEventType
22+ Filename string
23+ Destination string
24+ Progress float64 // Set when Type is DownloadEventProgress, normalized to 0.0-1.0.
25+ BytesReceived int64 // Best-effort received byte count for progress updates.
26+ BytesTotal int64 // Best-effort total byte count for progress updates when known.
27+ Error error // Set when Type is DownloadEventFailed
2328 }
2429
2530 // DownloadEventHandler receives download event notifications.
@@ -8,6 +8,8 @@ type DownloadEventType int
8 const (8 const (
9 // DownloadEventStarted indicates a download has begun.9 // DownloadEventStarted indicates a download has begun.
10 DownloadEventStarted DownloadEventType = iota10 DownloadEventStarted DownloadEventType = iota
11+ // DownloadEventProgress indicates download progress has advanced.
12+ DownloadEventProgress
11 // DownloadEventFinished indicates a download completed successfully.13 // DownloadEventFinished indicates a download completed successfully.
12 DownloadEventFinished14 DownloadEventFinished
13 // DownloadEventFailed indicates a download failed.15 // DownloadEventFailed indicates a download failed.
@@ -16,10 +18,13 @@ const (
16 18
17 // DownloadEvent contains information about a download event.19 // DownloadEvent contains information about a download event.
18 type DownloadEvent struct {20 type DownloadEvent struct {
19- Type DownloadEventType21+ Type DownloadEventType
20- Filename string22+ Filename string
21- Destination string23+ Destination string
22- Error error // Set when Type is DownloadEventFailed24+ Progress float64 // Set when Type is DownloadEventProgress, normalized to 0.0-1.0.
25+ BytesReceived int64 // Best-effort received byte count for progress updates.
26+ BytesTotal int64 // Best-effort total byte count for progress updates when known.
27+ Error error // Set when Type is DownloadEventFailed
23 }28 }
24 29
25 // DownloadEventHandler receives download event notifications.30 // DownloadEventHandler receives download event notifications.
A internal/domain/download/policy.go +87 -0
@@ -0,0 +1,87 @@
1+package download
2+
3+import (
4+ "mime"
5+ "net/url"
6+ "path"
7+ "strings"
8+)
9+
10+var forcedDownloadExtensions = map[string]struct{}{
11+ ".7z": {},
12+ ".apk": {},
13+ ".appimage": {},
14+ ".bz2": {},
15+ ".deb": {},
16+ ".dmg": {},
17+ ".exe": {},
18+ ".gz": {},
19+ ".img": {},
20+ ".iso": {},
21+ ".msi": {},
22+ ".pdf": {},
23+ ".pkg": {},
24+ ".rar": {},
25+ ".rpm": {},
26+ ".tar": {},
27+ ".tgz": {},
28+ ".xz": {},
29+ ".zip": {},
30+}
31+
32+var forcedDownloadMIMETypes = map[string]struct{}{
33+ "application/pdf": {},
34+ "application/x-apple-diskimage": {},
35+ "application/x-cd-image": {},
36+ "application/x-iso9660-image": {},
37+}
38+
39+// ShouldForceDownload returns true when the resource should be downloaded
40+// instead of navigated/rendered inline.
41+func ShouldForceDownload(uri, mimeType string) bool {
42+ return ShouldForceDownloadForMIMEType(mimeType) || ShouldForceDownloadForURI(uri)
43+}
44+
45+// ShouldForceDownloadForURI returns true when the URI path has a known
46+// download-only extension.
47+func ShouldForceDownloadForURI(uri string) bool {
48+ ext := strings.ToLower(path.Ext(uriPath(uri)))
49+ if ext == "" {
50+ return false
51+ }
52+ _, ok := forcedDownloadExtensions[ext]
53+ return ok
54+}
55+
56+// ShouldForceDownloadForMIMEType returns true when the MIME type maps to a
57+// known download-only artifact.
58+func ShouldForceDownloadForMIMEType(mimeType string) bool {
59+ if mimeType == "" {
60+ return false
61+ }
62+
63+ mediaType := strings.ToLower(strings.TrimSpace(mimeType))
64+ if parsed, _, err := mime.ParseMediaType(mediaType); err == nil {
65+ mediaType = parsed
66+ }
67+
68+ _, ok := forcedDownloadMIMETypes[mediaType]
69+ return ok
70+}
71+
72+func uriPath(raw string) string {
73+ if raw == "" {
74+ return ""
75+ }
76+
77+ parsed, err := url.Parse(raw)
78+ if err == nil && parsed.Path != "" {
79+ return parsed.Path
80+ }
81+
82+ if cut := strings.IndexAny(raw, "?#"); cut >= 0 {
83+ return raw[:cut]
84+ }
85+
86+ return raw
87+}
@@ -0,0 +1,87 @@
1+package download
2+
3+import (
4+ "mime"
5+ "net/url"
6+ "path"
7+ "strings"
8+)
9+
10+var forcedDownloadExtensions = map[string]struct{}{
11+ ".7z": {},
12+ ".apk": {},
13+ ".appimage": {},
14+ ".bz2": {},
15+ ".deb": {},
16+ ".dmg": {},
17+ ".exe": {},
18+ ".gz": {},
19+ ".img": {},
20+ ".iso": {},
21+ ".msi": {},
22+ ".pdf": {},
23+ ".pkg": {},
24+ ".rar": {},
25+ ".rpm": {},
26+ ".tar": {},
27+ ".tgz": {},
28+ ".xz": {},
29+ ".zip": {},
30+}
31+
32+var forcedDownloadMIMETypes = map[string]struct{}{
33+ "application/pdf": {},
34+ "application/x-apple-diskimage": {},
35+ "application/x-cd-image": {},
36+ "application/x-iso9660-image": {},
37+}
38+
39+// ShouldForceDownload returns true when the resource should be downloaded
40+// instead of navigated/rendered inline.
41+func ShouldForceDownload(uri, mimeType string) bool {
42+ return ShouldForceDownloadForMIMEType(mimeType) || ShouldForceDownloadForURI(uri)
43+}
44+
45+// ShouldForceDownloadForURI returns true when the URI path has a known
46+// download-only extension.
47+func ShouldForceDownloadForURI(uri string) bool {
48+ ext := strings.ToLower(path.Ext(uriPath(uri)))
49+ if ext == "" {
50+ return false
51+ }
52+ _, ok := forcedDownloadExtensions[ext]
53+ return ok
54+}
55+
56+// ShouldForceDownloadForMIMEType returns true when the MIME type maps to a
57+// known download-only artifact.
58+func ShouldForceDownloadForMIMEType(mimeType string) bool {
59+ if mimeType == "" {
60+ return false
61+ }
62+
63+ mediaType := strings.ToLower(strings.TrimSpace(mimeType))
64+ if parsed, _, err := mime.ParseMediaType(mediaType); err == nil {
65+ mediaType = parsed
66+ }
67+
68+ _, ok := forcedDownloadMIMETypes[mediaType]
69+ return ok
70+}
71+
72+func uriPath(raw string) string {
73+ if raw == "" {
74+ return ""
75+ }
76+
77+ parsed, err := url.Parse(raw)
78+ if err == nil && parsed.Path != "" {
79+ return parsed.Path
80+ }
81+
82+ if cut := strings.IndexAny(raw, "?#"); cut >= 0 {
83+ return raw[:cut]
84+ }
85+
86+ return raw
87+}
A internal/domain/download/policy_test.go +59 -0
@@ -0,0 +1,59 @@
1+package download
2+
3+import "testing"
4+
5+func TestShouldForceDownloadForURI(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ uri string
9+ expected bool
10+ }{
11+ {name: "pdf path", uri: "https://example.com/file.pdf", expected: true},
12+ {name: "iso path", uri: "https://example.com/linux.iso?mirror=1", expected: true},
13+ {name: "archive path", uri: "https://example.com/release.tar.gz", expected: true},
14+ {name: "plain html", uri: "https://example.com/index.html", expected: false},
15+ {name: "empty", uri: "", expected: false},
16+ }
17+
18+ for _, tt := range tests {
19+ t.Run(tt.name, func(t *testing.T) {
20+ if got := ShouldForceDownloadForURI(tt.uri); got != tt.expected {
21+ t.Fatalf("ShouldForceDownloadForURI(%q) = %v, want %v", tt.uri, got, tt.expected)
22+ }
23+ })
24+ }
25+}
26+
27+func TestShouldForceDownloadForMIMEType(t *testing.T) {
28+ tests := []struct {
29+ name string
30+ mimeType string
31+ expected bool
32+ }{
33+ {name: "pdf", mimeType: "application/pdf", expected: true},
34+ {name: "pdf with params", mimeType: "application/pdf; charset=binary", expected: true},
35+ {name: "iso image", mimeType: "application/x-iso9660-image", expected: true},
36+ {name: "html", mimeType: "text/html", expected: false},
37+ {name: "empty", mimeType: "", expected: false},
38+ }
39+
40+ for _, tt := range tests {
41+ t.Run(tt.name, func(t *testing.T) {
42+ if got := ShouldForceDownloadForMIMEType(tt.mimeType); got != tt.expected {
43+ t.Fatalf("ShouldForceDownloadForMIMEType(%q) = %v, want %v", tt.mimeType, got, tt.expected)
44+ }
45+ })
46+ }
47+}
48+
49+func TestShouldForceDownload(t *testing.T) {
50+ if !ShouldForceDownload("https://example.com/file.iso", "") {
51+ t.Fatal("expected URI extension to trigger forced download")
52+ }
53+ if !ShouldForceDownload("", "application/pdf") {
54+ t.Fatal("expected MIME type to trigger forced download")
55+ }
56+ if ShouldForceDownload("https://example.com/", "text/html") {
57+ t.Fatal("did not expect regular HTML to trigger forced download")
58+ }
59+}
@@ -0,0 +1,59 @@
1+package download
2+
3+import "testing"
4+
5+func TestShouldForceDownloadForURI(t *testing.T) {
6+ tests := []struct {
7+ name string
8+ uri string
9+ expected bool
10+ }{
11+ {name: "pdf path", uri: "https://example.com/file.pdf", expected: true},
12+ {name: "iso path", uri: "https://example.com/linux.iso?mirror=1", expected: true},
13+ {name: "archive path", uri: "https://example.com/release.tar.gz", expected: true},
14+ {name: "plain html", uri: "https://example.com/index.html", expected: false},
15+ {name: "empty", uri: "", expected: false},
16+ }
17+
18+ for _, tt := range tests {
19+ t.Run(tt.name, func(t *testing.T) {
20+ if got := ShouldForceDownloadForURI(tt.uri); got != tt.expected {
21+ t.Fatalf("ShouldForceDownloadForURI(%q) = %v, want %v", tt.uri, got, tt.expected)
22+ }
23+ })
24+ }
25+}
26+
27+func TestShouldForceDownloadForMIMEType(t *testing.T) {
28+ tests := []struct {
29+ name string
30+ mimeType string
31+ expected bool
32+ }{
33+ {name: "pdf", mimeType: "application/pdf", expected: true},
34+ {name: "pdf with params", mimeType: "application/pdf; charset=binary", expected: true},
35+ {name: "iso image", mimeType: "application/x-iso9660-image", expected: true},
36+ {name: "html", mimeType: "text/html", expected: false},
37+ {name: "empty", mimeType: "", expected: false},
38+ }
39+
40+ for _, tt := range tests {
41+ t.Run(tt.name, func(t *testing.T) {
42+ if got := ShouldForceDownloadForMIMEType(tt.mimeType); got != tt.expected {
43+ t.Fatalf("ShouldForceDownloadForMIMEType(%q) = %v, want %v", tt.mimeType, got, tt.expected)
44+ }
45+ })
46+ }
47+}
48+
49+func TestShouldForceDownload(t *testing.T) {
50+ if !ShouldForceDownload("https://example.com/file.iso", "") {
51+ t.Fatal("expected URI extension to trigger forced download")
52+ }
53+ if !ShouldForceDownload("", "application/pdf") {
54+ t.Fatal("expected MIME type to trigger forced download")
55+ }
56+ if ShouldForceDownload("https://example.com/", "text/html") {
57+ t.Fatal("did not expect regular HTML to trigger forced download")
58+ }
59+}
A internal/infrastructure/cef/download_handler.go +323 -0
@@ -0,0 +1,323 @@
1+package cef
2+
3+import (
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.
20+const maxFinishedEntries = 1024
21+
22+type 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+
29+type cefDownloadState struct {
30+ filename string
31+ destination string
32+ lastProgressPercent int32
33+}
34+
35+type cefDownloadInterruptError struct {
36+ filename string
37+ reason purecef.DownloadInterruptReason
38+}
39+
40+func (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+
49+func (e cefDownloadInterruptError) DownloadErrorCode() int {
50+ return int(e.reason)
51+}
52+
53+func (e cefDownloadInterruptError) DownloadErrorReason() string {
54+ return downloadInterruptReasonLabel(e.reason)
55+}
56+
57+type cefDownloadResponseAdapter struct {
58+ item purecef.DownloadItem
59+}
60+
61+var 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+
94+func downloadInterruptReasonLabel(reason purecef.DownloadInterruptReason) string {
95+ if label, ok := downloadInterruptReasonLabels[reason]; ok {
96+ return label
97+ }
98+ return "unknown"
99+}
100+
101+func 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+
113+func (h *downloadHandler) canDownload(_ purecef.Browser, _, _ string) bool {
114+ return true
115+}
116+
117+func (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+
150+func (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+
196+func (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+
219+func (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+
253+func 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+
273+func (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+
288+func 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+
304+func (a *cefDownloadResponseAdapter) GetMimeType() string {
305+ if a == nil || a.item == nil {
306+ return ""
307+ }
308+ return a.item.GetMimeType()
309+}
310+
311+func (a *cefDownloadResponseAdapter) GetSuggestedFilename() string {
312+ if a == nil || a.item == nil {
313+ return ""
314+ }
315+ return a.item.GetSuggestedFileName()
316+}
317+
318+func (a *cefDownloadResponseAdapter) GetUri() string {
319+ if a == nil || a.item == nil {
320+ return ""
321+ }
322+ return a.item.GetURL()
323+}
@@ -0,0 +1,323 @@
1+package cef
2+
3+import (
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.
20+const maxFinishedEntries = 1024
21+
22+type 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+
29+type cefDownloadState struct {
30+ filename string
31+ destination string
32+ lastProgressPercent int32
33+}
34+
35+type cefDownloadInterruptError struct {
36+ filename string
37+ reason purecef.DownloadInterruptReason
38+}
39+
40+func (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+
49+func (e cefDownloadInterruptError) DownloadErrorCode() int {
50+ return int(e.reason)
51+}
52+
53+func (e cefDownloadInterruptError) DownloadErrorReason() string {
54+ return downloadInterruptReasonLabel(e.reason)
55+}
56+
57+type cefDownloadResponseAdapter struct {
58+ item purecef.DownloadItem
59+}
60+
61+var 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+
94+func downloadInterruptReasonLabel(reason purecef.DownloadInterruptReason) string {
95+ if label, ok := downloadInterruptReasonLabels[reason]; ok {
96+ return label
97+ }
98+ return "unknown"
99+}
100+
101+func 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+
113+func (h *downloadHandler) canDownload(_ purecef.Browser, _, _ string) bool {
114+ return true
115+}
116+
117+func (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+
150+func (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+
196+func (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+
219+func (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+
253+func 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+
273+func (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+
288+func 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+
304+func (a *cefDownloadResponseAdapter) GetMimeType() string {
305+ if a == nil || a.item == nil {
306+ return ""
307+ }
308+ return a.item.GetMimeType()
309+}
310+
311+func (a *cefDownloadResponseAdapter) GetSuggestedFilename() string {
312+ if a == nil || a.item == nil {
313+ return ""
314+ }
315+ return a.item.GetSuggestedFileName()
316+}
317+
318+func (a *cefDownloadResponseAdapter) GetUri() string {
319+ if a == nil || a.item == nil {
320+ return ""
321+ }
322+ return a.item.GetURL()
323+}
A internal/infrastructure/cef/download_handler_test.go +209 -0
@@ -0,0 +1,209 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ "path/filepath"
7+ "testing"
8+
9+ purecef "github.com/bnema/purego-cef/cef"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/application/usecase"
14+)
15+
16+type stubDownloadItem struct {
17+ id uint32
18+ url string
19+ suggested string
20+ mimeType string
21+ fullPath string
22+ complete bool
23+ canceled bool
24+ interrupted bool
25+ reason purecef.DownloadInterruptReason
26+ percentComplete int32
27+ totalBytes int64
28+ receivedBytes int64
29+}
30+
31+func (s stubDownloadItem) IsValid() bool { return true }
32+func (s stubDownloadItem) IsInProgress() bool { return !s.complete && !s.canceled && !s.interrupted }
33+func (s stubDownloadItem) IsComplete() bool { return s.complete }
34+func (s stubDownloadItem) IsCanceled() bool { return s.canceled }
35+func (s stubDownloadItem) IsInterrupted() bool { return s.interrupted }
36+func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason {
37+ return s.reason
38+}
39+func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 }
40+func (s stubDownloadItem) GetPercentComplete() int32 { return s.percentComplete }
41+func (s stubDownloadItem) GetTotalBytes() int64 { return s.totalBytes }
42+func (s stubDownloadItem) GetReceivedBytes() int64 { return s.receivedBytes }
43+func (s stubDownloadItem) GetStartTime() uintptr { return 0 }
44+func (s stubDownloadItem) GetEndTime() uintptr { return 0 }
45+func (s stubDownloadItem) GetFullPath() string { return s.fullPath }
46+func (s stubDownloadItem) GetID() uint32 { return s.id }
47+func (s stubDownloadItem) GetURL() string { return s.url }
48+func (s stubDownloadItem) GetOriginalURL() string { return s.url }
49+func (s stubDownloadItem) GetSuggestedFileName() string { return s.suggested }
50+func (s stubDownloadItem) GetContentDisposition() string { return "" }
51+func (s stubDownloadItem) GetMimeType() string { return s.mimeType }
52+func (s stubDownloadItem) IsPaused() bool { return false }
53+
54+type stubBeforeDownloadCallback struct {
55+ path string
56+ showDialog int32
57+}
58+
59+func (s *stubBeforeDownloadCallback) Cont(downloadPath string, showDialog int32) {
60+ s.path = downloadPath
61+ s.showDialog = showDialog
62+}
63+
64+type captureDownloadEvents struct {
65+ events []port.DownloadEvent
66+}
67+
68+func (c *captureDownloadEvents) OnDownloadEvent(_ context.Context, event port.DownloadEvent) {
69+ c.events = append(c.events, event)
70+}
71+
72+func TestCEFDownloadHandlerLifecycle(t *testing.T) {
73+ ctx := context.Background()
74+ preparer := usecase.NewPrepareDownloadUseCase(nil)
75+ events := &captureDownloadEvents{}
76+ downloadDir := t.TempDir()
77+ handler := newDownloadHandler(downloadDir, events, preparer)
78+ callback := &stubBeforeDownloadCallback{}
79+
80+ item := stubDownloadItem{
81+ id: 7,
82+ url: "https://example.com/image.iso",
83+ suggested: "image.iso",
84+ mimeType: "application/x-iso9660-image",
85+ }
86+
87+ ok := handler.onBeforeDownload(ctx, nil, item, item.suggested, callback)
88+
89+ require.True(t, ok)
90+ require.Equal(t, filepath.Join(downloadDir, "image.iso"), callback.path)
91+ require.Len(t, events.events, 1)
92+ require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
93+
94+ item.complete = true
95+ item.fullPath = callback.path
96+ handler.onDownloadUpdated(ctx, item, nil)
97+ // Repeated completion updates should not emit duplicate finished events.
98+ handler.onDownloadUpdated(ctx, item, nil)
99+
100+ require.Len(t, events.events, 2)
101+ require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
102+ require.Equal(t, "image.iso", events.events[1].Filename)
103+
104+ // Active map should be cleaned up after terminal event.
105+ handler.mu.Lock()
106+ _, inActive := handler.active[7]
107+ _, inFinished := handler.finished[7]
108+ handler.mu.Unlock()
109+ require.False(t, inActive, "completed download should be removed from active map")
110+ require.True(t, inFinished, "completed download should be in finished set")
111+}
112+
113+func TestCanDownload_AllowsAllMethods(t *testing.T) {
114+ preparer := usecase.NewPrepareDownloadUseCase(nil)
115+ handler := newDownloadHandler("/tmp/downloads", nil, preparer)
116+
117+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "GET"))
118+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "POST"))
119+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "PUT"))
120+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", ""))
121+}
122+
123+func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {
124+ preparer := usecase.NewPrepareDownloadUseCase(nil)
125+ handler := newDownloadHandler("/tmp/downloads", nil, preparer)
126+
127+ // Simulate a download that was tracked through onBeforeDownload.
128+ handler.mu.Lock()
129+ handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1}
130+ handler.mu.Unlock()
131+
132+ require.True(t, handler.markFinished(42), "first terminal event should succeed")
133+
134+ // Simulate a concurrent update re-populating active before a duplicate terminal update.
135+ handler.mu.Lock()
136+ handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1}
137+ handler.mu.Unlock()
138+
139+ require.False(t, handler.markFinished(42), "second terminal event should be suppressed")
140+
141+ // Active map should not contain the entry.
142+ handler.mu.Lock()
143+ _, inActive := handler.active[42]
144+ handler.mu.Unlock()
145+ require.False(t, inActive)
146+}
147+
148+func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) {
149+ ctx := context.Background()
150+ preparer := usecase.NewPrepareDownloadUseCase(nil)
151+ events := &captureDownloadEvents{}
152+ handler := newDownloadHandler("/tmp/downloads", events, preparer)
153+ callback := &stubBeforeDownloadCallback{}
154+
155+ item := stubDownloadItem{
156+ id: 11,
157+ url: "https://example.com/archive.zip",
158+ suggested: "archive.zip",
159+ mimeType: "application/zip",
160+ }
161+
162+ require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback))
163+
164+ item.fullPath = callback.path
165+ item.percentComplete = 12
166+ item.receivedBytes = 12
167+ item.totalBytes = 100
168+ handler.onDownloadUpdated(ctx, item, nil)
169+ handler.onDownloadUpdated(ctx, item, nil)
170+
171+ item.percentComplete = 13
172+ item.receivedBytes = 13
173+ handler.onDownloadUpdated(ctx, item, nil)
174+
175+ require.Len(t, events.events, 3)
176+ require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
177+ require.Equal(t, port.DownloadEventProgress, events.events[1].Type)
178+ require.InEpsilon(t, 0.12, events.events[1].Progress, 0.0001)
179+ require.Equal(t, port.DownloadEventProgress, events.events[2].Type)
180+ require.InEpsilon(t, 0.13, events.events[2].Progress, 0.0001)
181+}
182+
183+func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {
184+ ctx := context.Background()
185+ preparer := usecase.NewPrepareDownloadUseCase(nil)
186+ events := &captureDownloadEvents{}
187+ handler := newDownloadHandler("/tmp/downloads", events, preparer)
188+ callback := &stubBeforeDownloadCallback{}
189+
190+ item := stubDownloadItem{
191+ id: 9,
192+ url: "https://example.com/ubuntu.iso",
193+ suggested: "ubuntu.iso",
194+ mimeType: "application/x-iso9660-image",
195+ }
196+
197+ require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback))
198+
199+ item.fullPath = callback.path
200+ item.interrupted = true
201+ item.reason = purecef.DownloadInterruptReasonServerBadContent
202+ handler.onDownloadUpdated(ctx, item, nil)
203+
204+ require.Len(t, events.events, 2)
205+ require.Error(t, events.events[1].Error)
206+ expectedCode := fmt.Sprintf("code=%d", purecef.DownloadInterruptReasonServerBadContent)
207+ require.ErrorContains(t, events.events[1].Error, expectedCode)
208+ require.ErrorContains(t, events.events[1].Error, "reason=server_bad_content")
209+}
@@ -0,0 +1,209 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ "path/filepath"
7+ "testing"
8+
9+ purecef "github.com/bnema/purego-cef/cef"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/application/usecase"
14+)
15+
16+type stubDownloadItem struct {
17+ id uint32
18+ url string
19+ suggested string
20+ mimeType string
21+ fullPath string
22+ complete bool
23+ canceled bool
24+ interrupted bool
25+ reason purecef.DownloadInterruptReason
26+ percentComplete int32
27+ totalBytes int64
28+ receivedBytes int64
29+}
30+
31+func (s stubDownloadItem) IsValid() bool { return true }
32+func (s stubDownloadItem) IsInProgress() bool { return !s.complete && !s.canceled && !s.interrupted }
33+func (s stubDownloadItem) IsComplete() bool { return s.complete }
34+func (s stubDownloadItem) IsCanceled() bool { return s.canceled }
35+func (s stubDownloadItem) IsInterrupted() bool { return s.interrupted }
36+func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason {
37+ return s.reason
38+}
39+func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 }
40+func (s stubDownloadItem) GetPercentComplete() int32 { return s.percentComplete }
41+func (s stubDownloadItem) GetTotalBytes() int64 { return s.totalBytes }
42+func (s stubDownloadItem) GetReceivedBytes() int64 { return s.receivedBytes }
43+func (s stubDownloadItem) GetStartTime() uintptr { return 0 }
44+func (s stubDownloadItem) GetEndTime() uintptr { return 0 }
45+func (s stubDownloadItem) GetFullPath() string { return s.fullPath }
46+func (s stubDownloadItem) GetID() uint32 { return s.id }
47+func (s stubDownloadItem) GetURL() string { return s.url }
48+func (s stubDownloadItem) GetOriginalURL() string { return s.url }
49+func (s stubDownloadItem) GetSuggestedFileName() string { return s.suggested }
50+func (s stubDownloadItem) GetContentDisposition() string { return "" }
51+func (s stubDownloadItem) GetMimeType() string { return s.mimeType }
52+func (s stubDownloadItem) IsPaused() bool { return false }
53+
54+type stubBeforeDownloadCallback struct {
55+ path string
56+ showDialog int32
57+}
58+
59+func (s *stubBeforeDownloadCallback) Cont(downloadPath string, showDialog int32) {
60+ s.path = downloadPath
61+ s.showDialog = showDialog
62+}
63+
64+type captureDownloadEvents struct {
65+ events []port.DownloadEvent
66+}
67+
68+func (c *captureDownloadEvents) OnDownloadEvent(_ context.Context, event port.DownloadEvent) {
69+ c.events = append(c.events, event)
70+}
71+
72+func TestCEFDownloadHandlerLifecycle(t *testing.T) {
73+ ctx := context.Background()
74+ preparer := usecase.NewPrepareDownloadUseCase(nil)
75+ events := &captureDownloadEvents{}
76+ downloadDir := t.TempDir()
77+ handler := newDownloadHandler(downloadDir, events, preparer)
78+ callback := &stubBeforeDownloadCallback{}
79+
80+ item := stubDownloadItem{
81+ id: 7,
82+ url: "https://example.com/image.iso",
83+ suggested: "image.iso",
84+ mimeType: "application/x-iso9660-image",
85+ }
86+
87+ ok := handler.onBeforeDownload(ctx, nil, item, item.suggested, callback)
88+
89+ require.True(t, ok)
90+ require.Equal(t, filepath.Join(downloadDir, "image.iso"), callback.path)
91+ require.Len(t, events.events, 1)
92+ require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
93+
94+ item.complete = true
95+ item.fullPath = callback.path
96+ handler.onDownloadUpdated(ctx, item, nil)
97+ // Repeated completion updates should not emit duplicate finished events.
98+ handler.onDownloadUpdated(ctx, item, nil)
99+
100+ require.Len(t, events.events, 2)
101+ require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
102+ require.Equal(t, "image.iso", events.events[1].Filename)
103+
104+ // Active map should be cleaned up after terminal event.
105+ handler.mu.Lock()
106+ _, inActive := handler.active[7]
107+ _, inFinished := handler.finished[7]
108+ handler.mu.Unlock()
109+ require.False(t, inActive, "completed download should be removed from active map")
110+ require.True(t, inFinished, "completed download should be in finished set")
111+}
112+
113+func TestCanDownload_AllowsAllMethods(t *testing.T) {
114+ preparer := usecase.NewPrepareDownloadUseCase(nil)
115+ handler := newDownloadHandler("/tmp/downloads", nil, preparer)
116+
117+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "GET"))
118+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "POST"))
119+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "PUT"))
120+ require.True(t, handler.canDownload(nil, "https://example.com/file.zip", ""))
121+}
122+
123+func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {
124+ preparer := usecase.NewPrepareDownloadUseCase(nil)
125+ handler := newDownloadHandler("/tmp/downloads", nil, preparer)
126+
127+ // Simulate a download that was tracked through onBeforeDownload.
128+ handler.mu.Lock()
129+ handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1}
130+ handler.mu.Unlock()
131+
132+ require.True(t, handler.markFinished(42), "first terminal event should succeed")
133+
134+ // Simulate a concurrent update re-populating active before a duplicate terminal update.
135+ handler.mu.Lock()
136+ handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1}
137+ handler.mu.Unlock()
138+
139+ require.False(t, handler.markFinished(42), "second terminal event should be suppressed")
140+
141+ // Active map should not contain the entry.
142+ handler.mu.Lock()
143+ _, inActive := handler.active[42]
144+ handler.mu.Unlock()
145+ require.False(t, inActive)
146+}
147+
148+func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) {
149+ ctx := context.Background()
150+ preparer := usecase.NewPrepareDownloadUseCase(nil)
151+ events := &captureDownloadEvents{}
152+ handler := newDownloadHandler("/tmp/downloads", events, preparer)
153+ callback := &stubBeforeDownloadCallback{}
154+
155+ item := stubDownloadItem{
156+ id: 11,
157+ url: "https://example.com/archive.zip",
158+ suggested: "archive.zip",
159+ mimeType: "application/zip",
160+ }
161+
162+ require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback))
163+
164+ item.fullPath = callback.path
165+ item.percentComplete = 12
166+ item.receivedBytes = 12
167+ item.totalBytes = 100
168+ handler.onDownloadUpdated(ctx, item, nil)
169+ handler.onDownloadUpdated(ctx, item, nil)
170+
171+ item.percentComplete = 13
172+ item.receivedBytes = 13
173+ handler.onDownloadUpdated(ctx, item, nil)
174+
175+ require.Len(t, events.events, 3)
176+ require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
177+ require.Equal(t, port.DownloadEventProgress, events.events[1].Type)
178+ require.InEpsilon(t, 0.12, events.events[1].Progress, 0.0001)
179+ require.Equal(t, port.DownloadEventProgress, events.events[2].Type)
180+ require.InEpsilon(t, 0.13, events.events[2].Progress, 0.0001)
181+}
182+
183+func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {
184+ ctx := context.Background()
185+ preparer := usecase.NewPrepareDownloadUseCase(nil)
186+ events := &captureDownloadEvents{}
187+ handler := newDownloadHandler("/tmp/downloads", events, preparer)
188+ callback := &stubBeforeDownloadCallback{}
189+
190+ item := stubDownloadItem{
191+ id: 9,
192+ url: "https://example.com/ubuntu.iso",
193+ suggested: "ubuntu.iso",
194+ mimeType: "application/x-iso9660-image",
195+ }
196+
197+ require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback))
198+
199+ item.fullPath = callback.path
200+ item.interrupted = true
201+ item.reason = purecef.DownloadInterruptReasonServerBadContent
202+ handler.onDownloadUpdated(ctx, item, nil)
203+
204+ require.Len(t, events.events, 2)
205+ require.Error(t, events.events[1].Error)
206+ expectedCode := fmt.Sprintf("code=%d", purecef.DownloadInterruptReasonServerBadContent)
207+ require.ErrorContains(t, events.events[1].Error, expectedCode)
208+ require.ErrorContains(t, events.events[1].Error, "reason=server_bad_content")
209+}
M internal/infrastructure/cef/engine.go +23 -5
@@ -3,6 +3,7 @@ package cef
33 import (
44 "context"
55 "crypto/subtle"
6+ "fmt"
67 "sync"
78 "sync/atomic"
89 "time"
@@ -42,6 +43,8 @@ type Engine struct {
4243 onClipboardCopied func(textLen int)
4344 resolver port.ImageDataResolver
4445 mediaClassifier MediaClassifier
46+ downloadMu sync.RWMutex
47+ downloadHandler *downloadHandler
4548 alreadyRunningAppRelaunchMu sync.RWMutex
4649 alreadyRunningAppRelaunchHandler func(string)
4750
@@ -256,14 +259,20 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent
256259 return e.registerAccentHandlers(ctx, e.messageRouter, handler)
257260 }
258261
259-// ConfigureDownloads sets up download handling (Phase 1 stub).
262+// ConfigureDownloads sets up download handling for all CEF webviews.
260263 func (e *Engine) ConfigureDownloads(
261264 _ context.Context,
262- _ string,
263- _ port.DownloadEventHandler,
264- _ port.DownloadPreparer,
265+ downloadPath string,
266+ eventHandler port.DownloadEventHandler,
267+ preparer port.DownloadPreparer,
265268 ) error {
266- return ErrDownloadsUnsupported
269+ if preparer == nil {
270+ return fmt.Errorf("cef: download preparer is required")
271+ }
272+ e.downloadMu.Lock()
273+ e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer)
274+ e.downloadMu.Unlock()
275+ return nil
267276 }
268277
269278 // OnToolkitReady is called after the UI toolkit has initialized.
@@ -447,6 +456,15 @@ func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce stri
447456 return valid
448457 }
449458
459+func (e *Engine) currentDownloadHandler() *downloadHandler {
460+ if e == nil {
461+ return nil
462+ }
463+ e.downloadMu.RLock()
464+ defer e.downloadMu.RUnlock()
465+ return e.downloadHandler
466+}
467+
450468 func (e *Engine) logTranscoderStartupState() {
451469 if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) {
452470 return
@@ -3,6 +3,7 @@ package cef
3 import (3 import (
4 "context"4 "context"
5 "crypto/subtle"5 "crypto/subtle"
6+ "fmt"
6 "sync"7 "sync"
7 "sync/atomic"8 "sync/atomic"
8 "time"9 "time"
@@ -42,6 +43,8 @@ type Engine struct {
42 onClipboardCopied func(textLen int)43 onClipboardCopied func(textLen int)
43 resolver port.ImageDataResolver44 resolver port.ImageDataResolver
44 mediaClassifier MediaClassifier45 mediaClassifier MediaClassifier
46+ downloadMu sync.RWMutex
47+ downloadHandler *downloadHandler
45 alreadyRunningAppRelaunchMu sync.RWMutex48 alreadyRunningAppRelaunchMu sync.RWMutex
46 alreadyRunningAppRelaunchHandler func(string)49 alreadyRunningAppRelaunchHandler func(string)
47 50
@@ -256,14 +259,20 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent
256 return e.registerAccentHandlers(ctx, e.messageRouter, handler)259 return e.registerAccentHandlers(ctx, e.messageRouter, handler)
257 }260 }
258 261
259-// ConfigureDownloads sets up download handling (Phase 1 stub).262+// ConfigureDownloads sets up download handling for all CEF webviews.
260 func (e *Engine) ConfigureDownloads(263 func (e *Engine) ConfigureDownloads(
261 _ context.Context,264 _ context.Context,
262- _ string,265+ downloadPath string,
263- _ port.DownloadEventHandler,266+ eventHandler port.DownloadEventHandler,
264- _ port.DownloadPreparer,267+ preparer port.DownloadPreparer,
265 ) error {268 ) error {
266- return ErrDownloadsUnsupported269+ if preparer == nil {
270+ return fmt.Errorf("cef: download preparer is required")
271+ }
272+ e.downloadMu.Lock()
273+ e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer)
274+ e.downloadMu.Unlock()
275+ return nil
267 }276 }
268 277
269 // OnToolkitReady is called after the UI toolkit has initialized.278 // OnToolkitReady is called after the UI toolkit has initialized.
@@ -447,6 +456,15 @@ func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce stri
447 return valid456 return valid
448 }457 }
449 458
459+func (e *Engine) currentDownloadHandler() *downloadHandler {
460+ if e == nil {
461+ return nil
462+ }
463+ e.downloadMu.RLock()
464+ defer e.downloadMu.RUnlock()
465+ return e.downloadHandler
466+}
467+
450 func (e *Engine) logTranscoderStartupState() {468 func (e *Engine) logTranscoderStartupState() {
451 if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) {469 if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) {
452 return470 return
M internal/infrastructure/cef/engine_contract_test.go +17 -2
@@ -5,13 +5,28 @@ import (
55 "testing"
66
77 "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
89 "github.com/stretchr/testify/require"
910 )
1011
11-func TestEngineConfigureDownloads_ReturnsUnsupported(t *testing.T) {
12+func TestEngineConfigureDownloads_StoresHandler(t *testing.T) {
1213 eng := &Engine{}
14+ preparer := usecase.NewPrepareDownloadUseCase(nil)
15+
16+ err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, preparer)
17+
18+ require.NoError(t, err)
19+ require.NotNil(t, eng.currentDownloadHandler())
20+}
21+
22+func TestEngineConfigureDownloads_NilPreparer(t *testing.T) {
23+ eng := &Engine{}
24+
1325 err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, nil)
14- require.ErrorIs(t, err, ErrDownloadsUnsupported)
26+
27+ require.Error(t, err)
28+ require.Contains(t, err.Error(), "preparer is required")
29+ require.Nil(t, eng.currentDownloadHandler())
1530 }
1631
1732 func TestWebViewFactoryCreateRelated_ReturnsUnsupported(t *testing.T) {
@@ -5,13 +5,28 @@ import (
5 "testing"5 "testing"
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
9 )10 )
10 11
11-func TestEngineConfigureDownloads_ReturnsUnsupported(t *testing.T) {12+func TestEngineConfigureDownloads_StoresHandler(t *testing.T) {
12 eng := &Engine{}13 eng := &Engine{}
14+ preparer := usecase.NewPrepareDownloadUseCase(nil)
15+
16+ err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, preparer)
17+
18+ require.NoError(t, err)
19+ require.NotNil(t, eng.currentDownloadHandler())
20+}
21+
22+func TestEngineConfigureDownloads_NilPreparer(t *testing.T) {
23+ eng := &Engine{}
24+
13 err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, nil)25 err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, nil)
14- require.ErrorIs(t, err, ErrDownloadsUnsupported)26+
27+ require.Error(t, err)
28+ require.Contains(t, err.Error(), "preparer is required")
29+ require.Nil(t, eng.currentDownloadHandler())
15 }30 }
16 31
17 func TestWebViewFactoryCreateRelated_ReturnsUnsupported(t *testing.T) {32 func TestWebViewFactoryCreateRelated_ReturnsUnsupported(t *testing.T) {
M internal/infrastructure/cef/webview_handlers.go +93 -15
@@ -7,6 +7,7 @@ import (
77 purecef "github.com/bnema/purego-cef/cef"
88
99 "github.com/bnema/dumber/internal/application/port"
10+ downloadutil "github.com/bnema/dumber/internal/domain/download"
1011 "github.com/bnema/dumber/internal/infrastructure/transcoder"
1112 "github.com/bnema/dumber/internal/logging"
1213 )
@@ -36,6 +37,7 @@ var (
3637 _ purecef.RequestHandler = (*handlerSet)(nil)
3738 _ purecef.AudioHandler = (*handlerSet)(nil)
3839 _ purecef.ContextMenuHandler = (*handlerSet)(nil)
40+ _ purecef.DownloadHandler = (*handlerSet)(nil)
3941 _ purecef.FindHandler = (*handlerSet)(nil)
4042 )
4143
@@ -54,19 +56,24 @@ func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return
5456 func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h }
5557 func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }
5658 func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
57-func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil }
58-func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
59-func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
60-func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
61-func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
62-func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
63-func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
64-func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
65-func (h *handlerSet) GetLifeSpanHandler() purecef.SafeLifeSpanHandler { return h }
66-func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
67-func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
68-func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }
69-func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
59+func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler {
60+ if h.downloadHandler() != nil {
61+ return h
62+ }
63+ return nil
64+}
65+func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
66+func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
67+func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
68+func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
69+func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
70+func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
71+func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
72+func (h *handlerSet) GetLifeSpanHandler() purecef.SafeLifeSpanHandler { return h }
73+func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
74+func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
75+func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }
76+func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
7077
7178 func (h *handlerSet) OnProcessMessageReceived(
7279 browser purecef.Browser,
@@ -779,8 +786,79 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
779786 // RequestHandler (11 methods)
780787 // ===========================================================================
781788
782-func (h *handlerSet) OnBeforeBrowse(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _, _ int32) bool {
783- return false
789+func (h *handlerSet) OnBeforeBrowse(browser purecef.Browser, frame purecef.Frame, request purecef.Request, _, _ int32) bool {
790+ if frame == nil || !frame.IsMain() || request == nil {
791+ return false
792+ }
793+
794+ handler := h.downloadHandler()
795+ if handler == nil || !strings.EqualFold(request.GetMethod(), "GET") {
796+ return false
797+ }
798+
799+ url := request.GetURL()
800+ if !downloadutil.ShouldForceDownloadForURI(url) || browser == nil {
801+ return false
802+ }
803+
804+ host := browser.GetHost()
805+ if host == nil {
806+ return false
807+ }
808+
809+ logging.FromContext(h.currentContext()).Debug().
810+ Str("url", logging.TruncateURL(url, maxTranscodingURLLength)).
811+ Msg("cef: forcing download for navigation")
812+
813+ host.StartDownload(url)
814+ return true
815+}
816+
817+func (h *handlerSet) CanDownload(browser purecef.Browser, url, requestMethod string) bool {
818+ handler := h.downloadHandler()
819+ if handler == nil {
820+ return false
821+ }
822+ return handler.canDownload(browser, url, requestMethod)
823+}
824+
825+func (h *handlerSet) OnBeforeDownload(
826+ browser purecef.Browser,
827+ downloadItem purecef.DownloadItem,
828+ suggestedName string,
829+ callback purecef.BeforeDownloadCallback,
830+) bool {
831+ handler := h.downloadHandler()
832+ if handler == nil {
833+ return false
834+ }
835+ return handler.onBeforeDownload(h.currentContext(), browser, downloadItem, suggestedName, callback)
836+}
837+
838+func (h *handlerSet) OnDownloadUpdated(
839+ _ purecef.Browser,
840+ downloadItem purecef.DownloadItem,
841+ callback purecef.DownloadItemCallback,
842+) {
843+ handler := h.downloadHandler()
844+ if handler == nil {
845+ return
846+ }
847+ handler.onDownloadUpdated(h.currentContext(), downloadItem, callback)
848+}
849+
850+func (h *handlerSet) downloadHandler() *downloadHandler {
851+ if h == nil || h.wv == nil || h.wv.engine == nil {
852+ return nil
853+ }
854+ return h.wv.engine.currentDownloadHandler()
855+}
856+
857+func (h *handlerSet) currentContext() context.Context {
858+ if h == nil || h.wv == nil || h.wv.ctx == nil {
859+ return context.Background()
860+ }
861+ return h.wv.ctx
784862 }
785863
786864 func (h *handlerSet) OnOpenUrlfromTab(
@@ -7,6 +7,7 @@ import (
7 purecef "github.com/bnema/purego-cef/cef"7 purecef "github.com/bnema/purego-cef/cef"
8 8
9 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
10+ downloadutil "github.com/bnema/dumber/internal/domain/download"
10 "github.com/bnema/dumber/internal/infrastructure/transcoder"11 "github.com/bnema/dumber/internal/infrastructure/transcoder"
11 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
12 )13 )
@@ -36,6 +37,7 @@ var (
36 _ purecef.RequestHandler = (*handlerSet)(nil)37 _ purecef.RequestHandler = (*handlerSet)(nil)
37 _ purecef.AudioHandler = (*handlerSet)(nil)38 _ purecef.AudioHandler = (*handlerSet)(nil)
38 _ purecef.ContextMenuHandler = (*handlerSet)(nil)39 _ purecef.ContextMenuHandler = (*handlerSet)(nil)
40+ _ purecef.DownloadHandler = (*handlerSet)(nil)
39 _ purecef.FindHandler = (*handlerSet)(nil)41 _ purecef.FindHandler = (*handlerSet)(nil)
40 )42 )
41 43
@@ -54,19 +56,24 @@ func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return
54 func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h }56 func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h }
55 func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }57 func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil }
56 func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }58 func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler { return h }
57-func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler { return nil }59+func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler {
58-func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }60+ if h.downloadHandler() != nil {
59-func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }61+ return h
60-func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }62+ }
61-func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }63+ return nil
62-func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }64+}
63-func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }65+func (h *handlerSet) GetDragHandler() purecef.DragHandler { return nil }
64-func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }66+func (h *handlerSet) GetFindHandler() purecef.FindHandler { return h }
65-func (h *handlerSet) GetLifeSpanHandler() purecef.SafeLifeSpanHandler { return h }67+func (h *handlerSet) GetFocusHandler() purecef.FocusHandler { return nil }
66-func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }68+func (h *handlerSet) GetFrameHandler() purecef.FrameHandler { return nil }
67-func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }69+func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
68-func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }70+func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler { return nil }
69-func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }71+func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return nil }
72+func (h *handlerSet) GetLifeSpanHandler() purecef.SafeLifeSpanHandler { return h }
73+func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
74+func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil }
75+func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }
76+func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
70 77
71 func (h *handlerSet) OnProcessMessageReceived(78 func (h *handlerSet) OnProcessMessageReceived(
72 browser purecef.Browser,79 browser purecef.Browser,
@@ -779,8 +786,79 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) {
779 // RequestHandler (11 methods)786 // RequestHandler (11 methods)
780 // ===========================================================================787 // ===========================================================================
781 788
782-func (h *handlerSet) OnBeforeBrowse(_ purecef.Browser, _ purecef.Frame, _ purecef.Request, _, _ int32) bool {789+func (h *handlerSet) OnBeforeBrowse(browser purecef.Browser, frame purecef.Frame, request purecef.Request, _, _ int32) bool {
783- return false790+ if frame == nil || !frame.IsMain() || request == nil {
791+ return false
792+ }
793+
794+ handler := h.downloadHandler()
795+ if handler == nil || !strings.EqualFold(request.GetMethod(), "GET") {
796+ return false
797+ }
798+
799+ url := request.GetURL()
800+ if !downloadutil.ShouldForceDownloadForURI(url) || browser == nil {
801+ return false
802+ }
803+
804+ host := browser.GetHost()
805+ if host == nil {
806+ return false
807+ }
808+
809+ logging.FromContext(h.currentContext()).Debug().
810+ Str("url", logging.TruncateURL(url, maxTranscodingURLLength)).
811+ Msg("cef: forcing download for navigation")
812+
813+ host.StartDownload(url)
814+ return true
815+}
816+
817+func (h *handlerSet) CanDownload(browser purecef.Browser, url, requestMethod string) bool {
818+ handler := h.downloadHandler()
819+ if handler == nil {
820+ return false
821+ }
822+ return handler.canDownload(browser, url, requestMethod)
823+}
824+
825+func (h *handlerSet) OnBeforeDownload(
826+ browser purecef.Browser,
827+ downloadItem purecef.DownloadItem,
828+ suggestedName string,
829+ callback purecef.BeforeDownloadCallback,
830+) bool {
831+ handler := h.downloadHandler()
832+ if handler == nil {
833+ return false
834+ }
835+ return handler.onBeforeDownload(h.currentContext(), browser, downloadItem, suggestedName, callback)
836+}
837+
838+func (h *handlerSet) OnDownloadUpdated(
839+ _ purecef.Browser,
840+ downloadItem purecef.DownloadItem,
841+ callback purecef.DownloadItemCallback,
842+) {
843+ handler := h.downloadHandler()
844+ if handler == nil {
845+ return
846+ }
847+ handler.onDownloadUpdated(h.currentContext(), downloadItem, callback)
848+}
849+
850+func (h *handlerSet) downloadHandler() *downloadHandler {
851+ if h == nil || h.wv == nil || h.wv.engine == nil {
852+ return nil
853+ }
854+ return h.wv.engine.currentDownloadHandler()
855+}
856+
857+func (h *handlerSet) currentContext() context.Context {
858+ if h == nil || h.wv == nil || h.wv.ctx == nil {
859+ return context.Background()
860+ }
861+ return h.wv.ctx
784 }862 }
785 863
786 func (h *handlerSet) OnOpenUrlfromTab(864 func (h *handlerSet) OnOpenUrlfromTab(
M internal/infrastructure/cef/webview_handlers_test.go +20 -0
@@ -23,6 +23,12 @@ type clipboardOrchestratorRecorder struct {
2323 selectionCalls int
2424 }
2525
26+type stubDownloadPreparer struct{}
27+
28+func (stubDownloadPreparer) Execute(context.Context, port.DownloadPrepareInput) *port.DownloadPrepareOutput {
29+ return &port.DownloadPrepareOutput{}
30+}
31+
2632 type controlledSelectionTimer struct {
2733 stopped bool
2834 }
@@ -374,6 +380,20 @@ func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) {
374380 require.Same(t, h, h.GetContextMenuHandler())
375381 }
376382
383+func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) {
384+ eng := &Engine{}
385+ require.NoError(t, eng.ConfigureDownloads(context.Background(), "/tmp/downloads", nil, stubDownloadPreparer{}))
386+
387+ h := &handlerSet{
388+ wv: &WebView{
389+ ctx: context.Background(),
390+ engine: eng,
391+ },
392+ }
393+
394+ require.Same(t, h, h.GetDownloadHandler())
395+}
396+
377397 func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) {
378398 wv := &WebView{
379399 id: 42,
@@ -23,6 +23,12 @@ type clipboardOrchestratorRecorder struct {
23 selectionCalls int23 selectionCalls int
24 }24 }
25 25
26+type stubDownloadPreparer struct{}
27+
28+func (stubDownloadPreparer) Execute(context.Context, port.DownloadPrepareInput) *port.DownloadPrepareOutput {
29+ return &port.DownloadPrepareOutput{}
30+}
31+
26 type controlledSelectionTimer struct {32 type controlledSelectionTimer struct {
27 stopped bool33 stopped bool
28 }34 }
@@ -374,6 +380,20 @@ func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) {
374 require.Same(t, h, h.GetContextMenuHandler())380 require.Same(t, h, h.GetContextMenuHandler())
375 }381 }
376 382
383+func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) {
384+ eng := &Engine{}
385+ require.NoError(t, eng.ConfigureDownloads(context.Background(), "/tmp/downloads", nil, stubDownloadPreparer{}))
386+
387+ h := &handlerSet{
388+ wv: &WebView{
389+ ctx: context.Background(),
390+ engine: eng,
391+ },
392+ }
393+
394+ require.Same(t, h, h.GetDownloadHandler())
395+}
396+
377 func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) {397 func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) {
378 wv := &WebView{398 wv := &WebView{
379 id: 42,399 id: 42,
A internal/infrastructure/downloadruntime/runtime.go +164 -0
@@ -0,0 +1,164 @@
1+package downloadruntime
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "sync"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+const dirPerm = 0o755
15+
16+type codedDownloadError interface {
17+ DownloadErrorCode() int
18+ DownloadErrorReason() string
19+}
20+
21+// Runtime shares common download preparation and event emission logic across engines.
22+type Runtime struct {
23+ mu sync.RWMutex
24+ downloadPath string
25+ eventHandler port.DownloadEventHandler
26+ preparer port.DownloadPreparer
27+}
28+
29+// New creates a Runtime. The caller must ensure preparer is non-nil;
30+// ConfigureDownloads validates this before reaching here.
31+func New(downloadPath string, eventHandler port.DownloadEventHandler, preparer port.DownloadPreparer) *Runtime {
32+ return &Runtime{
33+ downloadPath: downloadPath,
34+ eventHandler: eventHandler,
35+ preparer: preparer,
36+ }
37+}
38+
39+func (r *Runtime) SetDownloadPath(path string) {
40+ r.mu.Lock()
41+ defer r.mu.Unlock()
42+ r.downloadPath = path
43+}
44+
45+func (r *Runtime) ResolveDestination(
46+ ctx context.Context,
47+ suggestedFilename string,
48+ response port.DownloadResponse,
49+) (*port.DownloadPrepareOutput, error) {
50+ r.mu.RLock()
51+ downloadPath := r.downloadPath
52+ preparer := r.preparer
53+ r.mu.RUnlock()
54+
55+ if preparer == nil {
56+ return nil, fmt.Errorf("download preparer is required")
57+ }
58+
59+ if err := os.MkdirAll(downloadPath, dirPerm); err != nil {
60+ return nil, fmt.Errorf("failed to create download directory %q: %w", downloadPath, err)
61+ }
62+
63+ output := preparer.Execute(ctx, port.DownloadPrepareInput{
64+ SuggestedFilename: suggestedFilename,
65+ Response: response,
66+ DownloadDir: downloadPath,
67+ })
68+ if output == nil {
69+ return nil, fmt.Errorf("failed to prepare download destination for %q", suggestedFilename)
70+ }
71+
72+ return output, nil
73+}
74+
75+func (r *Runtime) EmitStarted(ctx context.Context, output *port.DownloadPrepareOutput) {
76+ if output == nil {
77+ return
78+ }
79+
80+ r.mu.RLock()
81+ eventHandler := r.eventHandler
82+ r.mu.RUnlock()
83+
84+ if eventHandler != nil {
85+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
86+ Type: port.DownloadEventStarted,
87+ Filename: output.Filename,
88+ Destination: output.DestinationPath,
89+ })
90+ }
91+
92+ logging.FromContext(ctx).Info().
93+ Str("filename", output.Filename).
94+ Str("destination", output.DestinationPath).
95+ Msg("download started")
96+}
97+
98+func (r *Runtime) EmitProgress(
99+ ctx context.Context,
100+ filename, destination string,
101+ progress float64,
102+ bytesReceived, bytesTotal int64,
103+) {
104+ r.mu.RLock()
105+ eventHandler := r.eventHandler
106+ r.mu.RUnlock()
107+
108+ if eventHandler != nil {
109+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
110+ Type: port.DownloadEventProgress,
111+ Filename: filename,
112+ Destination: destination,
113+ Progress: progress,
114+ BytesReceived: bytesReceived,
115+ BytesTotal: bytesTotal,
116+ })
117+ }
118+}
119+
120+func (r *Runtime) EmitFinished(ctx context.Context, filename, destination string) {
121+ r.mu.RLock()
122+ eventHandler := r.eventHandler
123+ r.mu.RUnlock()
124+
125+ if eventHandler != nil {
126+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
127+ Type: port.DownloadEventFinished,
128+ Filename: filename,
129+ Destination: destination,
130+ })
131+ }
132+
133+ logging.FromContext(ctx).Info().
134+ Str("filename", filename).
135+ Str("destination", destination).
136+ Msg("download finished")
137+}
138+
139+func (r *Runtime) EmitFailed(ctx context.Context, filename, destination string, err error) {
140+ r.mu.RLock()
141+ eventHandler := r.eventHandler
142+ r.mu.RUnlock()
143+
144+ if eventHandler != nil {
145+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
146+ Type: port.DownloadEventFailed,
147+ Filename: filename,
148+ Destination: destination,
149+ Error: err,
150+ })
151+ }
152+
153+ log := logging.FromContext(ctx).Warn().
154+ Err(err).
155+ Str("filename", filename).
156+ Str("destination", destination)
157+ var codedErr codedDownloadError
158+ if errors.As(err, &codedErr) {
159+ log = log.
160+ Int("error_code", codedErr.DownloadErrorCode()).
161+ Str("error_reason", codedErr.DownloadErrorReason())
162+ }
163+ log.Msg("download failed")
164+}
@@ -0,0 +1,164 @@
1+package downloadruntime
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "sync"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/logging"
12+)
13+
14+const dirPerm = 0o755
15+
16+type codedDownloadError interface {
17+ DownloadErrorCode() int
18+ DownloadErrorReason() string
19+}
20+
21+// Runtime shares common download preparation and event emission logic across engines.
22+type Runtime struct {
23+ mu sync.RWMutex
24+ downloadPath string
25+ eventHandler port.DownloadEventHandler
26+ preparer port.DownloadPreparer
27+}
28+
29+// New creates a Runtime. The caller must ensure preparer is non-nil;
30+// ConfigureDownloads validates this before reaching here.
31+func New(downloadPath string, eventHandler port.DownloadEventHandler, preparer port.DownloadPreparer) *Runtime {
32+ return &Runtime{
33+ downloadPath: downloadPath,
34+ eventHandler: eventHandler,
35+ preparer: preparer,
36+ }
37+}
38+
39+func (r *Runtime) SetDownloadPath(path string) {
40+ r.mu.Lock()
41+ defer r.mu.Unlock()
42+ r.downloadPath = path
43+}
44+
45+func (r *Runtime) ResolveDestination(
46+ ctx context.Context,
47+ suggestedFilename string,
48+ response port.DownloadResponse,
49+) (*port.DownloadPrepareOutput, error) {
50+ r.mu.RLock()
51+ downloadPath := r.downloadPath
52+ preparer := r.preparer
53+ r.mu.RUnlock()
54+
55+ if preparer == nil {
56+ return nil, fmt.Errorf("download preparer is required")
57+ }
58+
59+ if err := os.MkdirAll(downloadPath, dirPerm); err != nil {
60+ return nil, fmt.Errorf("failed to create download directory %q: %w", downloadPath, err)
61+ }
62+
63+ output := preparer.Execute(ctx, port.DownloadPrepareInput{
64+ SuggestedFilename: suggestedFilename,
65+ Response: response,
66+ DownloadDir: downloadPath,
67+ })
68+ if output == nil {
69+ return nil, fmt.Errorf("failed to prepare download destination for %q", suggestedFilename)
70+ }
71+
72+ return output, nil
73+}
74+
75+func (r *Runtime) EmitStarted(ctx context.Context, output *port.DownloadPrepareOutput) {
76+ if output == nil {
77+ return
78+ }
79+
80+ r.mu.RLock()
81+ eventHandler := r.eventHandler
82+ r.mu.RUnlock()
83+
84+ if eventHandler != nil {
85+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
86+ Type: port.DownloadEventStarted,
87+ Filename: output.Filename,
88+ Destination: output.DestinationPath,
89+ })
90+ }
91+
92+ logging.FromContext(ctx).Info().
93+ Str("filename", output.Filename).
94+ Str("destination", output.DestinationPath).
95+ Msg("download started")
96+}
97+
98+func (r *Runtime) EmitProgress(
99+ ctx context.Context,
100+ filename, destination string,
101+ progress float64,
102+ bytesReceived, bytesTotal int64,
103+) {
104+ r.mu.RLock()
105+ eventHandler := r.eventHandler
106+ r.mu.RUnlock()
107+
108+ if eventHandler != nil {
109+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
110+ Type: port.DownloadEventProgress,
111+ Filename: filename,
112+ Destination: destination,
113+ Progress: progress,
114+ BytesReceived: bytesReceived,
115+ BytesTotal: bytesTotal,
116+ })
117+ }
118+}
119+
120+func (r *Runtime) EmitFinished(ctx context.Context, filename, destination string) {
121+ r.mu.RLock()
122+ eventHandler := r.eventHandler
123+ r.mu.RUnlock()
124+
125+ if eventHandler != nil {
126+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
127+ Type: port.DownloadEventFinished,
128+ Filename: filename,
129+ Destination: destination,
130+ })
131+ }
132+
133+ logging.FromContext(ctx).Info().
134+ Str("filename", filename).
135+ Str("destination", destination).
136+ Msg("download finished")
137+}
138+
139+func (r *Runtime) EmitFailed(ctx context.Context, filename, destination string, err error) {
140+ r.mu.RLock()
141+ eventHandler := r.eventHandler
142+ r.mu.RUnlock()
143+
144+ if eventHandler != nil {
145+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
146+ Type: port.DownloadEventFailed,
147+ Filename: filename,
148+ Destination: destination,
149+ Error: err,
150+ })
151+ }
152+
153+ log := logging.FromContext(ctx).Warn().
154+ Err(err).
155+ Str("filename", filename).
156+ Str("destination", destination)
157+ var codedErr codedDownloadError
158+ if errors.As(err, &codedErr) {
159+ log = log.
160+ Int("error_code", codedErr.DownloadErrorCode()).
161+ Str("error_reason", codedErr.DownloadErrorReason())
162+ }
163+ log.Msg("download failed")
164+}
A internal/infrastructure/downloadruntime/runtime_test.go +66 -0
@@ -0,0 +1,66 @@
1+package downloadruntime
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/application/usecase"
11+)
12+
13+type stubResponse struct {
14+ mimeType string
15+ uri string
16+}
17+
18+func (s stubResponse) GetMimeType() string { return s.mimeType }
19+func (stubResponse) GetSuggestedFilename() string { return "" }
20+func (s stubResponse) GetUri() string { return s.uri }
21+
22+type captureEvents struct {
23+ events []port.DownloadEvent
24+}
25+
26+func (c *captureEvents) OnDownloadEvent(_ context.Context, event port.DownloadEvent) {
27+ c.events = append(c.events, event)
28+}
29+
30+func TestRuntimeResolveDestinationAndEvents(t *testing.T) {
31+ ctx := context.Background()
32+ events := &captureEvents{}
33+ runtime := New(t.TempDir(), events, usecase.NewPrepareDownloadUseCase(nil))
34+
35+ output, err := runtime.ResolveDestination(ctx, "artifact", stubResponse{mimeType: "application/pdf"})
36+
37+ require.NoError(t, err)
38+ require.Equal(t, "artifact.pdf", output.Filename)
39+ runtime.EmitStarted(ctx, output)
40+ runtime.EmitProgress(ctx, output.Filename, output.DestinationPath, 0.42, 42, 100)
41+ runtime.EmitFinished(ctx, output.Filename, output.DestinationPath)
42+
43+ require.Len(t, events.events, 3)
44+ require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
45+ require.Equal(t, port.DownloadEventProgress, events.events[1].Type)
46+ require.InEpsilon(t, 0.42, events.events[1].Progress, 0.0001)
47+ require.EqualValues(t, 42, events.events[1].BytesReceived)
48+ require.EqualValues(t, 100, events.events[1].BytesTotal)
49+ require.Equal(t, port.DownloadEventFinished, events.events[2].Type)
50+}
51+
52+func TestNewRuntime_NilPreparer_NoPanic(t *testing.T) {
53+ require.NotPanics(t, func() {
54+ _ = New("/tmp/downloads", nil, nil)
55+ })
56+}
57+
58+func TestRuntimeResolveDestination_NilPreparerReturnsError(t *testing.T) {
59+ runtime := New(t.TempDir(), nil, nil)
60+
61+ output, err := runtime.ResolveDestination(context.Background(), "artifact", stubResponse{})
62+
63+ require.Nil(t, output)
64+ require.Error(t, err)
65+ require.ErrorContains(t, err, "download preparer is required")
66+}
@@ -0,0 +1,66 @@
1+package downloadruntime
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/application/usecase"
11+)
12+
13+type stubResponse struct {
14+ mimeType string
15+ uri string
16+}
17+
18+func (s stubResponse) GetMimeType() string { return s.mimeType }
19+func (stubResponse) GetSuggestedFilename() string { return "" }
20+func (s stubResponse) GetUri() string { return s.uri }
21+
22+type captureEvents struct {
23+ events []port.DownloadEvent
24+}
25+
26+func (c *captureEvents) OnDownloadEvent(_ context.Context, event port.DownloadEvent) {
27+ c.events = append(c.events, event)
28+}
29+
30+func TestRuntimeResolveDestinationAndEvents(t *testing.T) {
31+ ctx := context.Background()
32+ events := &captureEvents{}
33+ runtime := New(t.TempDir(), events, usecase.NewPrepareDownloadUseCase(nil))
34+
35+ output, err := runtime.ResolveDestination(ctx, "artifact", stubResponse{mimeType: "application/pdf"})
36+
37+ require.NoError(t, err)
38+ require.Equal(t, "artifact.pdf", output.Filename)
39+ runtime.EmitStarted(ctx, output)
40+ runtime.EmitProgress(ctx, output.Filename, output.DestinationPath, 0.42, 42, 100)
41+ runtime.EmitFinished(ctx, output.Filename, output.DestinationPath)
42+
43+ require.Len(t, events.events, 3)
44+ require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
45+ require.Equal(t, port.DownloadEventProgress, events.events[1].Type)
46+ require.InEpsilon(t, 0.42, events.events[1].Progress, 0.0001)
47+ require.EqualValues(t, 42, events.events[1].BytesReceived)
48+ require.EqualValues(t, 100, events.events[1].BytesTotal)
49+ require.Equal(t, port.DownloadEventFinished, events.events[2].Type)
50+}
51+
52+func TestNewRuntime_NilPreparer_NoPanic(t *testing.T) {
53+ require.NotPanics(t, func() {
54+ _ = New("/tmp/downloads", nil, nil)
55+ })
56+}
57+
58+func TestRuntimeResolveDestination_NilPreparerReturnsError(t *testing.T) {
59+ runtime := New(t.TempDir(), nil, nil)
60+
61+ output, err := runtime.ResolveDestination(context.Background(), "artifact", stubResponse{})
62+
63+ require.Nil(t, output)
64+ require.Error(t, err)
65+ require.ErrorContains(t, err, "download preparer is required")
66+}
M internal/infrastructure/webkit/download_handler.go +113 -86
@@ -3,89 +3,90 @@ package webkit
33 import (
44 "context"
55 "fmt"
6- "os"
76 "sync"
87
98 "github.com/bnema/dumber/internal/application/port"
109 downloadutil "github.com/bnema/dumber/internal/domain/download"
10+ "github.com/bnema/dumber/internal/infrastructure/downloadruntime"
1111 "github.com/bnema/dumber/internal/logging"
1212 "github.com/bnema/puregotk/v4/glib"
1313 "github.com/bnema/puregotk/v4/webkit"
1414 )
1515
16-const (
17- // dirPerm is the permission mode for creating download directories.
18- dirPerm = 0755
19-)
20-
2116 // DownloadHandler manages WebKit downloads and notifies the UI layer.
2217 type DownloadHandler struct {
23- downloadPath string
24- eventHandler port.DownloadEventHandler
25- preparer port.DownloadPreparer
26- mu sync.RWMutex
18+ runtime *downloadruntime.Runtime
19+ mu sync.RWMutex
2720 }
2821
2922 // NewDownloadHandler creates a new download handler.
30-// Panics if preparer is nil (fail fast on misconfiguration).
23+// The caller (ConfigureDownloads) must validate that preparer is non-nil.
3124 func NewDownloadHandler(
3225 downloadPath string,
3326 handler port.DownloadEventHandler,
3427 preparer port.DownloadPreparer,
3528 ) *DownloadHandler {
36- if preparer == nil {
37- panic("preparer is required")
38- }
3929 return &DownloadHandler{
40- downloadPath: downloadPath,
41- eventHandler: handler,
42- preparer: preparer,
30+ runtime: downloadruntime.New(downloadPath, handler, preparer),
4331 }
4432 }
4533
4634 // downloadState tracks the state of a single download to prevent duplicate notifications.
4735 // This is allocated per-download to ensure proper isolation between concurrent downloads.
4836 type downloadState struct {
49- failed bool
50- mu sync.Mutex
37+ failed bool
38+ filename string
39+ destination string
40+ lastProgressPercent int
41+ mu sync.Mutex
5142 }
5243
5344 // HandleDownload sets up signal handlers for a new download.
5445 func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {
55- log := logging.FromContext(ctx)
46+ if download == nil {
47+ return
48+ }
5649
5750 h.mu.RLock()
58- downloadPath := h.downloadPath
59- eventHandler := h.eventHandler
60- preparer := h.preparer
51+ runtime := h.runtime
6152 h.mu.RUnlock()
6253
6354 // Track download state in a dedicated struct to ensure proper isolation.
6455 // Each download gets its own state to prevent interference between concurrent downloads.
65- state := &downloadState{}
66-
67- // Handle decide-destination signal to set download path.
68- //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)
69- decideDestCb := func(d webkit.Download, suggestedFilename string) bool {
70- // Ensure download directory exists.
71- if err := os.MkdirAll(downloadPath, dirPerm); err != nil {
72- log.Error().Err(err).Str("path", downloadPath).Msg("failed to create download directory")
73- d.Cancel()
74- return false
75- }
56+ state := &downloadState{lastProgressPercent: -1}
57+
58+ decideDestCb := makeDecideDestinationCallback(ctx, runtime, state)
59+ download.ConnectDecideDestination(&decideDestCb)
60+
61+ progressCb := makeProgressCallback(ctx, runtime, state)
62+ download.ConnectReceivedData(&progressCb)
63+
64+ failedCb := makeFailedCallback(ctx, runtime, state)
65+ download.ConnectFailed(&failedCb)
66+
67+ finishedCb := makeFinishedCallback(ctx, runtime, state)
68+ download.ConnectFinished(&finishedCb)
69+}
70+
71+func makeDecideDestinationCallback(
72+ ctx context.Context,
73+ runtime *downloadruntime.Runtime,
74+ state *downloadState,
75+) func(webkit.Download, string) bool {
76+ log := logging.FromContext(ctx)
7677
77- // Wrap WebKit response as port.DownloadResponse
78+ return func(d webkit.Download, suggestedFilename string) bool {
7879 var response port.DownloadResponse
7980 if resp := d.GetResponse(); resp != nil {
8081 response = &uriResponseAdapter{resp: resp}
8182 }
8283
83- // Use DownloadPreparer to resolve filename
84- output := preparer.Execute(ctx, port.DownloadPrepareInput{
85- SuggestedFilename: suggestedFilename,
86- Response: response,
87- DownloadDir: downloadPath,
88- })
84+ output, err := runtime.ResolveDestination(ctx, suggestedFilename, response)
85+ if err != nil {
86+ log.Error().Err(err).Msg("failed to prepare download destination")
87+ d.Cancel()
88+ return false
89+ }
8990
9091 log.Debug().
9192 Str("suggested", suggestedFilename).
@@ -93,84 +94,110 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
9394 Str("destPath", output.DestinationPath).
9495 Msg("setting download destination")
9596
96- // WebKit expects an absolute file path for the destination.
9797 d.SetDestination(output.DestinationPath)
9898
99- // Notify: download started.
100- if eventHandler != nil {
101- eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
102- Type: port.DownloadEventStarted,
103- Filename: output.Filename,
104- Destination: output.DestinationPath,
105- })
106- }
99+ state.mu.Lock()
100+ state.filename = output.Filename
101+ state.destination = output.DestinationPath
102+ state.lastProgressPercent = -1
103+ state.mu.Unlock()
107104
108- log.Info().
109- Str("filename", output.Filename).
110- Str("destination", output.DestinationPath).
111- Msg("download started")
105+ runtime.EmitStarted(ctx, output)
106+ emitWebKitDownloadProgress(ctx, runtime, state, d)
107+ return false
108+ }
109+}
112110
113- return false // false = we handled it synchronously
111+func makeProgressCallback(
112+ ctx context.Context,
113+ runtime *downloadruntime.Runtime,
114+ state *downloadState,
115+) func(webkit.Download, uint64) {
116+ return func(d webkit.Download, _ uint64) {
117+ emitWebKitDownloadProgress(ctx, runtime, state, d)
114118 }
115- download.ConnectDecideDestination(&decideDestCb)
119+}
116120
117- // Handle failed signal.
118- failedCb := func(d webkit.Download, gerr *glib.Error) {
121+func makeFailedCallback(
122+ ctx context.Context,
123+ runtime *downloadruntime.Runtime,
124+ state *downloadState,
125+) func(webkit.Download, *glib.Error) {
126+ return func(d webkit.Download, gerr *glib.Error) {
119127 state.mu.Lock()
120128 state.failed = true
121129 state.mu.Unlock()
122130
123131 dest := d.GetDestination()
124132 filename := downloadutil.ExtractFilenameFromDestination(dest)
125-
126- // Create error for the failed download.
127133 downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo())
128-
129- if eventHandler != nil {
130- eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
131- Type: port.DownloadEventFailed,
132- Filename: filename,
133- Destination: dest,
134- Error: downloadErr,
135- })
136- }
137-
138- log.Warn().Str("filename", filename).Msg("download failed")
134+ runtime.EmitFailed(ctx, filename, dest, downloadErr)
139135 }
140- download.ConnectFailed(&failedCb)
136+}
141137
142- // Handle finished signal.
143- finishedCb := func(d webkit.Download) {
138+func makeFinishedCallback(
139+ ctx context.Context,
140+ runtime *downloadruntime.Runtime,
141+ state *downloadState,
142+) func(webkit.Download) {
143+ return func(d webkit.Download) {
144144 state.mu.Lock()
145145 failed := state.failed
146146 state.mu.Unlock()
147-
148- // Don't send finished event if we already sent a failed event.
149147 if failed {
150148 return
151149 }
152150
153151 dest := d.GetDestination()
154152 filename := downloadutil.ExtractFilenameFromDestination(dest)
153+ runtime.EmitFinished(ctx, filename, dest)
154+ }
155+}
155156
156- if eventHandler != nil {
157- eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
158- Type: port.DownloadEventFinished,
159- Filename: filename,
160- Destination: dest,
161- })
162- }
157+func emitWebKitDownloadProgress(
158+ ctx context.Context,
159+ runtime *downloadruntime.Runtime,
160+ state *downloadState,
161+ d webkit.Download,
162+) {
163+ state.mu.Lock()
164+ if state.failed {
165+ state.mu.Unlock()
166+ return
167+ }
168+ filename := state.filename
169+ destination := state.destination
170+ lastPercent := state.lastProgressPercent
171+ state.mu.Unlock()
172+ if filename == "" || destination == "" {
173+ return
174+ }
163175
164- log.Info().Str("filename", filename).Msg("download finished")
176+ progress := d.GetEstimatedProgress()
177+ if progress <= 0 || progress >= 1 {
178+ return
165179 }
166- download.ConnectFinished(&finishedCb)
180+ percent := int(progress * 100)
181+ if percent <= lastPercent {
182+ return
183+ }
184+
185+ state.mu.Lock()
186+ if state.failed || percent <= state.lastProgressPercent {
187+ state.mu.Unlock()
188+ return
189+ }
190+ state.lastProgressPercent = percent
191+ state.mu.Unlock()
192+
193+ runtime.EmitProgress(ctx, filename, destination, progress, int64(d.GetReceivedDataLength()), 0)
167194 }
168195
169196 // SetDownloadPath updates the download directory.
170197 func (h *DownloadHandler) SetDownloadPath(path string) {
171198 h.mu.Lock()
172199 defer h.mu.Unlock()
173- h.downloadPath = path
200+ h.runtime.SetDownloadPath(path)
174201 }
175202
176203 // uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse.
@@ -3,89 +3,90 @@ package webkit
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "os"
7 "sync"6 "sync"
8 7
9 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
10 downloadutil "github.com/bnema/dumber/internal/domain/download"9 downloadutil "github.com/bnema/dumber/internal/domain/download"
10+ "github.com/bnema/dumber/internal/infrastructure/downloadruntime"
11 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/puregotk/v4/glib"12 "github.com/bnema/puregotk/v4/glib"
13 "github.com/bnema/puregotk/v4/webkit"13 "github.com/bnema/puregotk/v4/webkit"
14 )14 )
15 15
16-const (
17- // dirPerm is the permission mode for creating download directories.
18- dirPerm = 0755
19-)
20-
21 // DownloadHandler manages WebKit downloads and notifies the UI layer.16 // DownloadHandler manages WebKit downloads and notifies the UI layer.
22 type DownloadHandler struct {17 type DownloadHandler struct {
23- downloadPath string18+ runtime *downloadruntime.Runtime
24- eventHandler port.DownloadEventHandler19+ mu sync.RWMutex
25- preparer port.DownloadPreparer
26- mu sync.RWMutex
27 }20 }
28 21
29 // NewDownloadHandler creates a new download handler.22 // NewDownloadHandler creates a new download handler.
30-// Panics if preparer is nil (fail fast on misconfiguration).23+// The caller (ConfigureDownloads) must validate that preparer is non-nil.
31 func NewDownloadHandler(24 func NewDownloadHandler(
32 downloadPath string,25 downloadPath string,
33 handler port.DownloadEventHandler,26 handler port.DownloadEventHandler,
34 preparer port.DownloadPreparer,27 preparer port.DownloadPreparer,
35 ) *DownloadHandler {28 ) *DownloadHandler {
36- if preparer == nil {
37- panic("preparer is required")
38- }
39 return &DownloadHandler{29 return &DownloadHandler{
40- downloadPath: downloadPath,30+ runtime: downloadruntime.New(downloadPath, handler, preparer),
41- eventHandler: handler,
42- preparer: preparer,
43 }31 }
44 }32 }
45 33
46 // downloadState tracks the state of a single download to prevent duplicate notifications.34 // downloadState tracks the state of a single download to prevent duplicate notifications.
47 // This is allocated per-download to ensure proper isolation between concurrent downloads.35 // This is allocated per-download to ensure proper isolation between concurrent downloads.
48 type downloadState struct {36 type downloadState struct {
49- failed bool37+ failed bool
50- mu sync.Mutex38+ filename string
39+ destination string
40+ lastProgressPercent int
41+ mu sync.Mutex
51 }42 }
52 43
53 // HandleDownload sets up signal handlers for a new download.44 // HandleDownload sets up signal handlers for a new download.
54 func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {45 func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {
55- log := logging.FromContext(ctx)46+ if download == nil {
47+ return
48+ }
56 49
57 h.mu.RLock()50 h.mu.RLock()
58- downloadPath := h.downloadPath51+ runtime := h.runtime
59- eventHandler := h.eventHandler
60- preparer := h.preparer
61 h.mu.RUnlock()52 h.mu.RUnlock()
62 53
63 // Track download state in a dedicated struct to ensure proper isolation.54 // Track download state in a dedicated struct to ensure proper isolation.
64 // Each download gets its own state to prevent interference between concurrent downloads.55 // Each download gets its own state to prevent interference between concurrent downloads.
65- state := &downloadState{}56+ state := &downloadState{lastProgressPercent: -1}
66-57+
67- // Handle decide-destination signal to set download path.58+ decideDestCb := makeDecideDestinationCallback(ctx, runtime, state)
68- //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)59+ download.ConnectDecideDestination(&decideDestCb)
69- decideDestCb := func(d webkit.Download, suggestedFilename string) bool {60+
70- // Ensure download directory exists.61+ progressCb := makeProgressCallback(ctx, runtime, state)
71- if err := os.MkdirAll(downloadPath, dirPerm); err != nil {62+ download.ConnectReceivedData(&progressCb)
72- log.Error().Err(err).Str("path", downloadPath).Msg("failed to create download directory")63+
73- d.Cancel()64+ failedCb := makeFailedCallback(ctx, runtime, state)
74- return false65+ download.ConnectFailed(&failedCb)
75- }66+
67+ finishedCb := makeFinishedCallback(ctx, runtime, state)
68+ download.ConnectFinished(&finishedCb)
69+}
70+
71+func makeDecideDestinationCallback(
72+ ctx context.Context,
73+ runtime *downloadruntime.Runtime,
74+ state *downloadState,
75+) func(webkit.Download, string) bool {
76+ log := logging.FromContext(ctx)
76 77
77- // Wrap WebKit response as port.DownloadResponse78+ return func(d webkit.Download, suggestedFilename string) bool {
78 var response port.DownloadResponse79 var response port.DownloadResponse
79 if resp := d.GetResponse(); resp != nil {80 if resp := d.GetResponse(); resp != nil {
80 response = &uriResponseAdapter{resp: resp}81 response = &uriResponseAdapter{resp: resp}
81 }82 }
82 83
83- // Use DownloadPreparer to resolve filename84+ output, err := runtime.ResolveDestination(ctx, suggestedFilename, response)
84- output := preparer.Execute(ctx, port.DownloadPrepareInput{85+ if err != nil {
85- SuggestedFilename: suggestedFilename,86+ log.Error().Err(err).Msg("failed to prepare download destination")
86- Response: response,87+ d.Cancel()
87- DownloadDir: downloadPath,88+ return false
88- })89+ }
89 90
90 log.Debug().91 log.Debug().
91 Str("suggested", suggestedFilename).92 Str("suggested", suggestedFilename).
@@ -93,84 +94,110 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
93 Str("destPath", output.DestinationPath).94 Str("destPath", output.DestinationPath).
94 Msg("setting download destination")95 Msg("setting download destination")
95 96
96- // WebKit expects an absolute file path for the destination.
97 d.SetDestination(output.DestinationPath)97 d.SetDestination(output.DestinationPath)
98 98
99- // Notify: download started.99+ state.mu.Lock()
100- if eventHandler != nil {100+ state.filename = output.Filename
101- eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{101+ state.destination = output.DestinationPath
102- Type: port.DownloadEventStarted,102+ state.lastProgressPercent = -1
103- Filename: output.Filename,103+ state.mu.Unlock()
104- Destination: output.DestinationPath,
105- })
106- }
107 104
108- log.Info().105+ runtime.EmitStarted(ctx, output)
109- Str("filename", output.Filename).106+ emitWebKitDownloadProgress(ctx, runtime, state, d)
110- Str("destination", output.DestinationPath).107+ return false
111- Msg("download started")108+ }
109+}
112 110
113- return false // false = we handled it synchronously111+func makeProgressCallback(
112+ ctx context.Context,
113+ runtime *downloadruntime.Runtime,
114+ state *downloadState,
115+) func(webkit.Download, uint64) {
116+ return func(d webkit.Download, _ uint64) {
117+ emitWebKitDownloadProgress(ctx, runtime, state, d)
114 }118 }
115- download.ConnectDecideDestination(&decideDestCb)119+}
116 120
117- // Handle failed signal.121+func makeFailedCallback(
118- failedCb := func(d webkit.Download, gerr *glib.Error) {122+ ctx context.Context,
123+ runtime *downloadruntime.Runtime,
124+ state *downloadState,
125+) func(webkit.Download, *glib.Error) {
126+ return func(d webkit.Download, gerr *glib.Error) {
119 state.mu.Lock()127 state.mu.Lock()
120 state.failed = true128 state.failed = true
121 state.mu.Unlock()129 state.mu.Unlock()
122 130
123 dest := d.GetDestination()131 dest := d.GetDestination()
124 filename := downloadutil.ExtractFilenameFromDestination(dest)132 filename := downloadutil.ExtractFilenameFromDestination(dest)
125-
126- // Create error for the failed download.
127 downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo())133 downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo())
128-134+ runtime.EmitFailed(ctx, filename, dest, downloadErr)
129- if eventHandler != nil {
130- eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
131- Type: port.DownloadEventFailed,
132- Filename: filename,
133- Destination: dest,
134- Error: downloadErr,
135- })
136- }
137-
138- log.Warn().Str("filename", filename).Msg("download failed")
139 }135 }
140- download.ConnectFailed(&failedCb)136+}
141 137
142- // Handle finished signal.138+func makeFinishedCallback(
143- finishedCb := func(d webkit.Download) {139+ ctx context.Context,
140+ runtime *downloadruntime.Runtime,
141+ state *downloadState,
142+) func(webkit.Download) {
143+ return func(d webkit.Download) {
144 state.mu.Lock()144 state.mu.Lock()
145 failed := state.failed145 failed := state.failed
146 state.mu.Unlock()146 state.mu.Unlock()
147-
148- // Don't send finished event if we already sent a failed event.
149 if failed {147 if failed {
150 return148 return
151 }149 }
152 150
153 dest := d.GetDestination()151 dest := d.GetDestination()
154 filename := downloadutil.ExtractFilenameFromDestination(dest)152 filename := downloadutil.ExtractFilenameFromDestination(dest)
153+ runtime.EmitFinished(ctx, filename, dest)
154+ }
155+}
155 156
156- if eventHandler != nil {157+func emitWebKitDownloadProgress(
157- eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{158+ ctx context.Context,
158- Type: port.DownloadEventFinished,159+ runtime *downloadruntime.Runtime,
159- Filename: filename,160+ state *downloadState,
160- Destination: dest,161+ d webkit.Download,
161- })162+) {
162- }163+ state.mu.Lock()
164+ if state.failed {
165+ state.mu.Unlock()
166+ return
167+ }
168+ filename := state.filename
169+ destination := state.destination
170+ lastPercent := state.lastProgressPercent
171+ state.mu.Unlock()
172+ if filename == "" || destination == "" {
173+ return
174+ }
163 175
164- log.Info().Str("filename", filename).Msg("download finished")176+ progress := d.GetEstimatedProgress()
177+ if progress <= 0 || progress >= 1 {
178+ return
165 }179 }
166- download.ConnectFinished(&finishedCb)180+ percent := int(progress * 100)
181+ if percent <= lastPercent {
182+ return
183+ }
184+
185+ state.mu.Lock()
186+ if state.failed || percent <= state.lastProgressPercent {
187+ state.mu.Unlock()
188+ return
189+ }
190+ state.lastProgressPercent = percent
191+ state.mu.Unlock()
192+
193+ runtime.EmitProgress(ctx, filename, destination, progress, int64(d.GetReceivedDataLength()), 0)
167 }194 }
168 195
169 // SetDownloadPath updates the download directory.196 // SetDownloadPath updates the download directory.
170 func (h *DownloadHandler) SetDownloadPath(path string) {197 func (h *DownloadHandler) SetDownloadPath(path string) {
171 h.mu.Lock()198 h.mu.Lock()
172 defer h.mu.Unlock()199 defer h.mu.Unlock()
173- h.downloadPath = path200+ h.runtime.SetDownloadPath(path)
174 }201 }
175 202
176 // uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse.203 // uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse.
M internal/infrastructure/webkit/download_handler_test.go +11 -6
@@ -1,10 +1,13 @@
11 package webkit
22
33 import (
4+ "context"
5+ "path/filepath"
46 "testing"
57
68 "github.com/bnema/dumber/internal/application/usecase"
79 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
811 )
912
1013 func TestNewDownloadHandler(t *testing.T) {
@@ -14,15 +17,13 @@ func TestNewDownloadHandler(t *testing.T) {
1417 handler := NewDownloadHandler("/tmp/downloads", nil, prepareUC)
1518
1619 assert.NotNil(t, handler)
17- assert.Equal(t, "/tmp/downloads", handler.downloadPath)
18- assert.Nil(t, handler.eventHandler)
19- assert.NotNil(t, handler.preparer)
20+ require.NotNil(t, handler.runtime)
2021 })
2122
2223 t.Run("creates handler with custom path", func(t *testing.T) {
2324 handler := NewDownloadHandler("/custom/path", nil, prepareUC)
2425
25- assert.Equal(t, "/custom/path", handler.downloadPath)
26+ require.NotNil(t, handler.runtime)
2627 })
2728 }
2829
@@ -30,9 +31,13 @@ func TestDownloadHandler_SetDownloadPath(t *testing.T) {
3031 prepareUC := usecase.NewPrepareDownloadUseCase(nil)
3132 handler := NewDownloadHandler("/initial/path", nil, prepareUC)
3233
33- handler.SetDownloadPath("/new/path")
34+ newPath := t.TempDir()
35+ handler.SetDownloadPath(newPath)
3436
35- assert.Equal(t, "/new/path", handler.downloadPath)
37+ output, err := handler.runtime.ResolveDestination(context.Background(), "artifact.pdf", nil)
38+
39+ require.NoError(t, err)
40+ assert.Equal(t, filepath.Join(newPath, "artifact.pdf"), output.DestinationPath)
3641 }
3742
3843 func TestURIResponseAdapter(t *testing.T) {
@@ -1,10 +1,13 @@
1 package webkit1 package webkit
2 2
3 import (3 import (
4+ "context"
5+ "path/filepath"
4 "testing"6 "testing"
5 7
6 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
7 "github.com/stretchr/testify/assert"9 "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
8 )11 )
9 12
10 func TestNewDownloadHandler(t *testing.T) {13 func TestNewDownloadHandler(t *testing.T) {
@@ -14,15 +17,13 @@ func TestNewDownloadHandler(t *testing.T) {
14 handler := NewDownloadHandler("/tmp/downloads", nil, prepareUC)17 handler := NewDownloadHandler("/tmp/downloads", nil, prepareUC)
15 18
16 assert.NotNil(t, handler)19 assert.NotNil(t, handler)
17- assert.Equal(t, "/tmp/downloads", handler.downloadPath)20+ require.NotNil(t, handler.runtime)
18- assert.Nil(t, handler.eventHandler)
19- assert.NotNil(t, handler.preparer)
20 })21 })
21 22
22 t.Run("creates handler with custom path", func(t *testing.T) {23 t.Run("creates handler with custom path", func(t *testing.T) {
23 handler := NewDownloadHandler("/custom/path", nil, prepareUC)24 handler := NewDownloadHandler("/custom/path", nil, prepareUC)
24 25
25- assert.Equal(t, "/custom/path", handler.downloadPath)26+ require.NotNil(t, handler.runtime)
26 })27 })
27 }28 }
28 29
@@ -30,9 +31,13 @@ func TestDownloadHandler_SetDownloadPath(t *testing.T) {
30 prepareUC := usecase.NewPrepareDownloadUseCase(nil)31 prepareUC := usecase.NewPrepareDownloadUseCase(nil)
31 handler := NewDownloadHandler("/initial/path", nil, prepareUC)32 handler := NewDownloadHandler("/initial/path", nil, prepareUC)
32 33
33- handler.SetDownloadPath("/new/path")34+ newPath := t.TempDir()
35+ handler.SetDownloadPath(newPath)
34 36
35- assert.Equal(t, "/new/path", handler.downloadPath)37+ output, err := handler.runtime.ResolveDestination(context.Background(), "artifact.pdf", nil)
38+
39+ require.NoError(t, err)
40+ assert.Equal(t, filepath.Join(newPath, "artifact.pdf"), output.DestinationPath)
36 }41 }
37 42
38 func TestURIResponseAdapter(t *testing.T) {43 func TestURIResponseAdapter(t *testing.T) {
M internal/infrastructure/webkit/engine.go +3 -0
@@ -120,6 +120,9 @@ func (e *Engine) ConfigureDownloads(
120120 if e.wkCtx == nil {
121121 return fmt.Errorf("webkit context not initialized")
122122 }
123+ if preparer == nil {
124+ return fmt.Errorf("download preparer is required")
125+ }
123126 handler := NewDownloadHandler(downloadPath, eventHandler, preparer)
124127 e.wkCtx.SetDownloadHandler(ctx, handler)
125128 e.downloadPath = downloadPath
@@ -120,6 +120,9 @@ func (e *Engine) ConfigureDownloads(
120 if e.wkCtx == nil {120 if e.wkCtx == nil {
121 return fmt.Errorf("webkit context not initialized")121 return fmt.Errorf("webkit context not initialized")
122 }122 }
123+ if preparer == nil {
124+ return fmt.Errorf("download preparer is required")
125+ }
123 handler := NewDownloadHandler(downloadPath, eventHandler, preparer)126 handler := NewDownloadHandler(downloadPath, eventHandler, preparer)
124 e.wkCtx.SetDownloadHandler(ctx, handler)127 e.wkCtx.SetDownloadHandler(ctx, handler)
125 e.downloadPath = downloadPath128 e.downloadPath = downloadPath
M internal/infrastructure/webkit/engine_runtime_test.go +8 -0
@@ -46,6 +46,14 @@ func TestEngine_ConfigureDownloads_NilContext(t *testing.T) {
4646 require.Contains(t, err.Error(), "webkit context not initialized")
4747 }
4848
49+func TestNewDownloadHandler_NilPreparer_NoPanic(t *testing.T) {
50+ // After removing the panic, NewDownloadHandler with nil preparer should
51+ // not panic. The caller (ConfigureDownloads) is responsible for validation.
52+ require.NotPanics(t, func() {
53+ _ = NewDownloadHandler("/tmp", nil, nil)
54+ })
55+}
56+
4957 func TestEngine_Close_NilPool(t *testing.T) {
5058 e := &Engine{}
5159 err := e.Close()
@@ -46,6 +46,14 @@ func TestEngine_ConfigureDownloads_NilContext(t *testing.T) {
46 require.Contains(t, err.Error(), "webkit context not initialized")46 require.Contains(t, err.Error(), "webkit context not initialized")
47 }47 }
48 48
49+func TestNewDownloadHandler_NilPreparer_NoPanic(t *testing.T) {
50+ // After removing the panic, NewDownloadHandler with nil preparer should
51+ // not panic. The caller (ConfigureDownloads) is responsible for validation.
52+ require.NotPanics(t, func() {
53+ _ = NewDownloadHandler("/tmp", nil, nil)
54+ })
55+}
56+
49 func TestEngine_Close_NilPool(t *testing.T) {57 func TestEngine_Close_NilPool(t *testing.T) {
50 e := &Engine{}58 e := &Engine{}
51 err := e.Close()59 err := e.Close()
M internal/infrastructure/webkit/webview.go +2 -16
@@ -11,6 +11,7 @@ import (
1111 "time"
1212
1313 "github.com/bnema/dumber/internal/application/port"
14+ downloadutil "github.com/bnema/dumber/internal/domain/download"
1415 "github.com/bnema/dumber/internal/domain/entity"
1516 urlutil "github.com/bnema/dumber/internal/domain/url"
1617 "github.com/bnema/dumber/internal/infrastructure/desktop"
@@ -824,22 +825,7 @@ func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool {
824825 return false
825826 }
826827
827- mimeType := strings.ToLower(response.GetMimeType())
828- if strings.HasPrefix(mimeType, "application/pdf") {
829- return true
830- }
831-
832- uri := response.GetUri()
833- if uri == "" {
834- return false
835- }
836-
837- parsed, err := url.Parse(uri)
838- if err != nil {
839- return strings.Contains(strings.ToLower(uri), ".pdf")
840- }
841-
842- return strings.HasSuffix(strings.ToLower(parsed.Path), ".pdf")
828+ return downloadutil.ShouldForceDownload(response.GetUri(), response.GetMimeType())
843829 }
844830
845831 func (wv *WebView) connectEnterFullscreenSignal() {
@@ -11,6 +11,7 @@ import (
11 "time"11 "time"
12 12
13 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
14+ downloadutil "github.com/bnema/dumber/internal/domain/download"
14 "github.com/bnema/dumber/internal/domain/entity"15 "github.com/bnema/dumber/internal/domain/entity"
15 urlutil "github.com/bnema/dumber/internal/domain/url"16 urlutil "github.com/bnema/dumber/internal/domain/url"
16 "github.com/bnema/dumber/internal/infrastructure/desktop"17 "github.com/bnema/dumber/internal/infrastructure/desktop"
@@ -824,22 +825,7 @@ func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool {
824 return false825 return false
825 }826 }
826 827
827- mimeType := strings.ToLower(response.GetMimeType())828+ return downloadutil.ShouldForceDownload(response.GetUri(), response.GetMimeType())
828- if strings.HasPrefix(mimeType, "application/pdf") {
829- return true
830- }
831-
832- uri := response.GetUri()
833- if uri == "" {
834- return false
835- }
836-
837- parsed, err := url.Parse(uri)
838- if err != nil {
839- return strings.Contains(strings.ToLower(uri), ".pdf")
840- }
841-
842- return strings.HasSuffix(strings.ToLower(parsed.Path), ".pdf")
843 }829 }
844 830
845 func (wv *WebView) connectEnterFullscreenSignal() {831 func (wv *WebView) connectEnterFullscreenSignal() {
M internal/ui/app.go +134 -16
@@ -40,12 +40,14 @@ const (
4040 AppID = "com.github.bnema.dumber"
4141 appTitle = "Dumber"
4242 // crashReportToastDurationMs keeps startup crash-report toast visible longer.
43- crashReportToastDurationMs = 5000
44- floatingPaneFallbackWidth = 1200
45- floatingPaneFallbackHeight = 800
46- floatingPaneIDPrefix = "floating-pane:"
47- floatingSessionIDDefault = "default"
48- floatingPaneVisibleClass = "floating-pane-visible"
43+ crashReportToastDurationMs = 5000
44+ downloadToastTerminalDuration = 2500
45+ downloadProgressRoundBias = 0.5
46+ floatingPaneFallbackWidth = 1200
47+ floatingPaneFallbackHeight = 800
48+ floatingPaneIDPrefix = "floating-pane:"
49+ floatingSessionIDDefault = "default"
50+ floatingPaneVisibleClass = "floating-pane-visible"
4951 )
5052
5153 func gtkApplicationFlags() gio.ApplicationFlags {
@@ -889,7 +891,7 @@ func (a *App) initDownloadHandler(ctx context.Context) {
889891 }
890892
891893 // Create download event adapter to show toasts.
892- eventAdapter := &downloadEventAdapter{app: a}
894+ eventAdapter := newDownloadEventAdapter(a)
893895
894896 // Create use case for preparing download destinations with file deduplication.
895897 prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem)
@@ -904,25 +906,141 @@ func (a *App) initDownloadHandler(ctx context.Context) {
904906
905907 // downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
906908 type downloadEventAdapter struct {
907- app *App
909+ app *App
910+ mu sync.Mutex
911+ active map[string]downloadToastState
912+}
913+
914+type downloadToastState struct {
915+ filename string
916+ percent int
917+}
918+
919+type downloadToastSpec struct {
920+ message string
921+ level component.ToastLevel
922+ duration int
923+ show bool
924+}
925+
926+func newDownloadEventAdapter(app *App) *downloadEventAdapter {
927+ return &downloadEventAdapter{
928+ app: app,
929+ active: make(map[string]downloadToastState),
930+ }
908931 }
909932
910933 func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
934+ spec := d.toastSpecForEvent(event)
935+ if !spec.show {
936+ return
937+ }
938+
911939 // Must schedule on GTK main thread.
912940 cb := glib.SourceFunc(func(_ uintptr) bool {
913- switch event.Type {
914- case port.DownloadEventStarted:
915- d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download started: "+event.Filename, component.ToastInfo)
916- case port.DownloadEventFinished:
917- d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
918- case port.DownloadEventFailed:
919- d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download failed: "+event.Filename, component.ToastError)
920- }
941+ d.app.showToastOnLastFocusedBrowserWindow(
942+ ctx,
943+ spec.message,
944+ spec.level,
945+ component.WithDuration(spec.duration),
946+ )
921947 return false
922948 })
923949 glib.IdleAdd(&cb, 0)
924950 }
925951
952+func (d *downloadEventAdapter) toastSpecForEvent(event port.DownloadEvent) downloadToastSpec {
953+ key := downloadToastKey(event)
954+ filename := event.Filename
955+ if filename == "" {
956+ filename = filepath.Base(event.Destination)
957+ }
958+ if filename == "" {
959+ filename = "download"
960+ }
961+
962+ d.mu.Lock()
963+ defer d.mu.Unlock()
964+
965+ state := d.active[key]
966+ if state.filename == "" {
967+ state.filename = filename
968+ }
969+
970+ switch event.Type {
971+ case port.DownloadEventStarted:
972+ state.percent = 0
973+ d.active[key] = state
974+ return downloadToastSpec{
975+ message: fmt.Sprintf("Download started: %s (0%%)", state.filename),
976+ level: component.ToastInfo,
977+ duration: 0,
978+ show: true,
979+ }
980+ case port.DownloadEventProgress:
981+ percent := downloadProgressPercent(event.Progress)
982+ if percent <= state.percent {
983+ if _, ok := d.active[key]; !ok {
984+ d.active[key] = state
985+ }
986+ return downloadToastSpec{}
987+ }
988+ state.percent = percent
989+ d.active[key] = state
990+ return downloadToastSpec{
991+ message: fmt.Sprintf("Downloading: %s (%d%%)", state.filename, percent),
992+ level: component.ToastInfo,
993+ duration: 0,
994+ show: true,
995+ }
996+ case port.DownloadEventFinished:
997+ delete(d.active, key)
998+ return downloadToastSpec{
999+ message: "Download complete: " + state.filename,
1000+ level: component.ToastSuccess,
1001+ duration: downloadToastTerminalDuration,
1002+ show: true,
1003+ }
1004+ case port.DownloadEventFailed:
1005+ delete(d.active, key)
1006+ return downloadToastSpec{
1007+ message: "Download failed: " + state.filename,
1008+ level: component.ToastError,
1009+ duration: downloadToastTerminalDuration,
1010+ show: true,
1011+ }
1012+ default:
1013+ return downloadToastSpec{}
1014+ }
1015+}
1016+
1017+func downloadToastKey(event port.DownloadEvent) string {
1018+ if event.Destination != "" {
1019+ return event.Destination
1020+ }
1021+ if event.Filename != "" {
1022+ return event.Filename
1023+ }
1024+ return "download"
1025+}
1026+
1027+func downloadProgressPercent(progress float64) int {
1028+ if progress <= 0 {
1029+ return 0
1030+ }
1031+ if progress >= 1 {
1032+ return 100
1033+ }
1034+ percent := int(progress*100 + downloadProgressRoundBias)
1035+ if percent < 0 {
1036+ return 0
1037+ }
1038+ if percent > 100 {
1039+ return 100
1040+ }
1041+ return percent
1042+}
1043+
9261044 // autoCopyConfigFn implements port.AutoCopyConfig using a function closure.
9271045 type autoCopyConfigFn struct {
9281046 fn func() bool
@@ -40,12 +40,14 @@ const (
40 AppID = "com.github.bnema.dumber"40 AppID = "com.github.bnema.dumber"
41 appTitle = "Dumber"41 appTitle = "Dumber"
42 // crashReportToastDurationMs keeps startup crash-report toast visible longer.42 // crashReportToastDurationMs keeps startup crash-report toast visible longer.
43- crashReportToastDurationMs = 500043+ crashReportToastDurationMs = 5000
44- floatingPaneFallbackWidth = 120044+ downloadToastTerminalDuration = 2500
45- floatingPaneFallbackHeight = 80045+ downloadProgressRoundBias = 0.5
46- floatingPaneIDPrefix = "floating-pane:"46+ floatingPaneFallbackWidth = 1200
47- floatingSessionIDDefault = "default"47+ floatingPaneFallbackHeight = 800
48- floatingPaneVisibleClass = "floating-pane-visible"48+ floatingPaneIDPrefix = "floating-pane:"
49+ floatingSessionIDDefault = "default"
50+ floatingPaneVisibleClass = "floating-pane-visible"
49 )51 )
50 52
51 func gtkApplicationFlags() gio.ApplicationFlags {53 func gtkApplicationFlags() gio.ApplicationFlags {
@@ -889,7 +891,7 @@ func (a *App) initDownloadHandler(ctx context.Context) {
889 }891 }
890 892
891 // Create download event adapter to show toasts.893 // Create download event adapter to show toasts.
892- eventAdapter := &downloadEventAdapter{app: a}894+ eventAdapter := newDownloadEventAdapter(a)
893 895
894 // Create use case for preparing download destinations with file deduplication.896 // Create use case for preparing download destinations with file deduplication.
895 prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem)897 prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem)
@@ -904,25 +906,141 @@ func (a *App) initDownloadHandler(ctx context.Context) {
904 906
905 // downloadEventAdapter implements port.DownloadEventHandler and shows toasts.907 // downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
906 type downloadEventAdapter struct {908 type downloadEventAdapter struct {
907- app *App909+ app *App
910+ mu sync.Mutex
911+ active map[string]downloadToastState
912+}
913+
914+type downloadToastState struct {
915+ filename string
916+ percent int
917+}
918+
919+type downloadToastSpec struct {
920+ message string
921+ level component.ToastLevel
922+ duration int
923+ show bool
924+}
925+
926+func newDownloadEventAdapter(app *App) *downloadEventAdapter {
927+ return &downloadEventAdapter{
928+ app: app,
929+ active: make(map[string]downloadToastState),
930+ }
908 }931 }
909 932
910 func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {933 func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
934+ spec := d.toastSpecForEvent(event)
935+ if !spec.show {
936+ return
937+ }
938+
911 // Must schedule on GTK main thread.939 // Must schedule on GTK main thread.
912 cb := glib.SourceFunc(func(_ uintptr) bool {940 cb := glib.SourceFunc(func(_ uintptr) bool {
913- switch event.Type {941+ d.app.showToastOnLastFocusedBrowserWindow(
914- case port.DownloadEventStarted:942+ ctx,
915- d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download started: "+event.Filename, component.ToastInfo)943+ spec.message,
916- case port.DownloadEventFinished:944+ spec.level,
917- d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download complete: "+event.Filename, component.ToastSuccess)945+ component.WithDuration(spec.duration),
918- case port.DownloadEventFailed:946+ )
919- d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download failed: "+event.Filename, component.ToastError)
920- }
921 return false947 return false
922 })948 })
923 glib.IdleAdd(&cb, 0)949 glib.IdleAdd(&cb, 0)
924 }950 }
925 951
952+func (d *downloadEventAdapter) toastSpecForEvent(event port.DownloadEvent) downloadToastSpec {
953+ key := downloadToastKey(event)
954+ filename := event.Filename
955+ if filename == "" {
956+ filename = filepath.Base(event.Destination)
957+ }
958+ if filename == "" {
959+ filename = "download"
960+ }
961+
962+ d.mu.Lock()
963+ defer d.mu.Unlock()
964+
965+ state := d.active[key]
966+ if state.filename == "" {
967+ state.filename = filename
968+ }
969+
970+ switch event.Type {
971+ case port.DownloadEventStarted:
972+ state.percent = 0
973+ d.active[key] = state
974+ return downloadToastSpec{
975+ message: fmt.Sprintf("Download started: %s (0%%)", state.filename),
976+ level: component.ToastInfo,
977+ duration: 0,
978+ show: true,
979+ }
980+ case port.DownloadEventProgress:
981+ percent := downloadProgressPercent(event.Progress)
982+ if percent <= state.percent {
983+ if _, ok := d.active[key]; !ok {
984+ d.active[key] = state
985+ }
986+ return downloadToastSpec{}
987+ }
988+ state.percent = percent
989+ d.active[key] = state
990+ return downloadToastSpec{
991+ message: fmt.Sprintf("Downloading: %s (%d%%)", state.filename, percent),
992+ level: component.ToastInfo,
993+ duration: 0,
994+ show: true,
995+ }
996+ case port.DownloadEventFinished:
997+ delete(d.active, key)
998+ return downloadToastSpec{
999+ message: "Download complete: " + state.filename,
1000+ level: component.ToastSuccess,
1001+ duration: downloadToastTerminalDuration,
1002+ show: true,
1003+ }
1004+ case port.DownloadEventFailed:
1005+ delete(d.active, key)
1006+ return downloadToastSpec{
1007+ message: "Download failed: " + state.filename,
1008+ level: component.ToastError,
1009+ duration: downloadToastTerminalDuration,
1010+ show: true,
1011+ }
1012+ default:
1013+ return downloadToastSpec{}
1014+ }
1015+}
1016+
1017+func downloadToastKey(event port.DownloadEvent) string {
1018+ if event.Destination != "" {
1019+ return event.Destination
1020+ }
1021+ if event.Filename != "" {
1022+ return event.Filename
1023+ }
1024+ return "download"
1025+}
1026+
1027+func downloadProgressPercent(progress float64) int {
1028+ if progress <= 0 {
1029+ return 0
1030+ }
1031+ if progress >= 1 {
1032+ return 100
1033+ }
1034+ percent := int(progress*100 + downloadProgressRoundBias)
1035+ if percent < 0 {
1036+ return 0
1037+ }
1038+ if percent > 100 {
1039+ return 100
1040+ }
1041+ return percent
1042+}
1043+
926 // autoCopyConfigFn implements port.AutoCopyConfig using a function closure.1044 // autoCopyConfigFn implements port.AutoCopyConfig using a function closure.
927 type autoCopyConfigFn struct {1045 type autoCopyConfigFn struct {
928 fn func() bool1046 fn func() bool
A internal/ui/app_download_test.go +86 -0
@@ -0,0 +1,86 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/ui/component"
10+)
11+
12+func TestDownloadEventAdapterToastSpecLifecycle(t *testing.T) {
13+ adapter := newDownloadEventAdapter(nil)
14+ event := port.DownloadEvent{
15+ Filename: "archlinux.iso",
16+ Destination: "/tmp/archlinux.iso",
17+ }
18+
19+ started := adapter.toastSpecForEvent(port.DownloadEvent{
20+ Type: port.DownloadEventStarted,
21+ Filename: event.Filename,
22+ Destination: event.Destination,
23+ })
24+ require.True(t, started.show)
25+ require.Equal(t, component.ToastInfo, started.level)
26+ require.Equal(t, 0, started.duration)
27+ require.Equal(t, "Download started: archlinux.iso (0%)", started.message)
28+
29+ progress := adapter.toastSpecForEvent(port.DownloadEvent{
30+ Type: port.DownloadEventProgress,
31+ Filename: event.Filename,
32+ Destination: event.Destination,
33+ Progress: 0.37,
34+ })
35+ require.True(t, progress.show)
36+ require.Equal(t, component.ToastInfo, progress.level)
37+ require.Equal(t, 0, progress.duration)
38+ require.Equal(t, "Downloading: archlinux.iso (37%)", progress.message)
39+
40+ finished := adapter.toastSpecForEvent(port.DownloadEvent{
41+ Type: port.DownloadEventFinished,
42+ Filename: event.Filename,
43+ Destination: event.Destination,
44+ })
45+ require.True(t, finished.show)
46+ require.Equal(t, component.ToastSuccess, finished.level)
47+ require.Equal(t, downloadToastTerminalDuration, finished.duration)
48+ require.Equal(t, "Download complete: archlinux.iso", finished.message)
49+}
50+
51+func TestDownloadEventAdapterToastSpecSuppressesDuplicateProgress(t *testing.T) {
52+ adapter := newDownloadEventAdapter(nil)
53+ event := port.DownloadEvent{
54+ Filename: "ubuntu.iso",
55+ Destination: "/tmp/ubuntu.iso",
56+ }
57+
58+ adapter.toastSpecForEvent(port.DownloadEvent{
59+ Type: port.DownloadEventStarted,
60+ Filename: event.Filename,
61+ Destination: event.Destination,
62+ })
63+
64+ first := adapter.toastSpecForEvent(port.DownloadEvent{
65+ Type: port.DownloadEventProgress,
66+ Filename: event.Filename,
67+ Destination: event.Destination,
68+ Progress: 0.12,
69+ })
70+ require.True(t, first.show)
71+
72+ duplicate := adapter.toastSpecForEvent(port.DownloadEvent{
73+ Type: port.DownloadEventProgress,
74+ Filename: event.Filename,
75+ Destination: event.Destination,
76+ Progress: 0.12,
77+ })
78+ require.False(t, duplicate.show)
79+}
80+
81+func TestDownloadProgressPercentRoundsReasonably(t *testing.T) {
82+ require.Equal(t, 0, downloadProgressPercent(0))
83+ require.Equal(t, 1, downloadProgressPercent(0.0051))
84+ require.Equal(t, 37, downloadProgressPercent(0.37))
85+ require.Equal(t, 100, downloadProgressPercent(1.2))
86+}
@@ -0,0 +1,86 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/ui/component"
10+)
11+
12+func TestDownloadEventAdapterToastSpecLifecycle(t *testing.T) {
13+ adapter := newDownloadEventAdapter(nil)
14+ event := port.DownloadEvent{
15+ Filename: "archlinux.iso",
16+ Destination: "/tmp/archlinux.iso",
17+ }
18+
19+ started := adapter.toastSpecForEvent(port.DownloadEvent{
20+ Type: port.DownloadEventStarted,
21+ Filename: event.Filename,
22+ Destination: event.Destination,
23+ })
24+ require.True(t, started.show)
25+ require.Equal(t, component.ToastInfo, started.level)
26+ require.Equal(t, 0, started.duration)
27+ require.Equal(t, "Download started: archlinux.iso (0%)", started.message)
28+
29+ progress := adapter.toastSpecForEvent(port.DownloadEvent{
30+ Type: port.DownloadEventProgress,
31+ Filename: event.Filename,
32+ Destination: event.Destination,
33+ Progress: 0.37,
34+ })
35+ require.True(t, progress.show)
36+ require.Equal(t, component.ToastInfo, progress.level)
37+ require.Equal(t, 0, progress.duration)
38+ require.Equal(t, "Downloading: archlinux.iso (37%)", progress.message)
39+
40+ finished := adapter.toastSpecForEvent(port.DownloadEvent{
41+ Type: port.DownloadEventFinished,
42+ Filename: event.Filename,
43+ Destination: event.Destination,
44+ })
45+ require.True(t, finished.show)
46+ require.Equal(t, component.ToastSuccess, finished.level)
47+ require.Equal(t, downloadToastTerminalDuration, finished.duration)
48+ require.Equal(t, "Download complete: archlinux.iso", finished.message)
49+}
50+
51+func TestDownloadEventAdapterToastSpecSuppressesDuplicateProgress(t *testing.T) {
52+ adapter := newDownloadEventAdapter(nil)
53+ event := port.DownloadEvent{
54+ Filename: "ubuntu.iso",
55+ Destination: "/tmp/ubuntu.iso",
56+ }
57+
58+ adapter.toastSpecForEvent(port.DownloadEvent{
59+ Type: port.DownloadEventStarted,
60+ Filename: event.Filename,
61+ Destination: event.Destination,
62+ })
63+
64+ first := adapter.toastSpecForEvent(port.DownloadEvent{
65+ Type: port.DownloadEventProgress,
66+ Filename: event.Filename,
67+ Destination: event.Destination,
68+ Progress: 0.12,
69+ })
70+ require.True(t, first.show)
71+
72+ duplicate := adapter.toastSpecForEvent(port.DownloadEvent{
73+ Type: port.DownloadEventProgress,
74+ Filename: event.Filename,
75+ Destination: event.Destination,
76+ Progress: 0.12,
77+ })
78+ require.False(t, duplicate.show)
79+}
80+
81+func TestDownloadProgressPercentRoundsReasonably(t *testing.T) {
82+ require.Equal(t, 0, downloadProgressPercent(0))
83+ require.Equal(t, 1, downloadProgressPercent(0.0051))
84+ require.Equal(t, 37, downloadProgressPercent(0.37))
85+ require.Equal(t, 100, downloadProgressPercent(1.2))
86+}

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