detached 533930e

Add shared and CEF download handling

Agent-Logs-Url: https://github.com/bnema/dumber/sessions/1efb54e2-29d8-4b77-924a-ea1cf2cfa5ec

Co-authored-by: bnema <5212137+bnema@users.noreply.github.com>
commit
533930e616ceadb0fab9f461836a0ebdc5d91241
author
copilot-swe-agent[bot] · · 2026-04-14 12:55 UTC
committer
brice · · 2026-04-19 19:05 UTC
parent
70cc912
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +620 -40
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 +243 -0
@@ -0,0 +1,243 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "os"
8+ "strings"
9+ "sync"
10+
11+ purecef "github.com/bnema/purego-cef/cef"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ downloadutil "github.com/bnema/dumber/internal/domain/download"
15+ "github.com/bnema/dumber/internal/logging"
16+)
17+
18+const cefDownloadDirPerm = 0o755
19+
20+type downloadHandler struct {
21+ downloadPath string
22+ eventHandler port.DownloadEventHandler
23+ preparer port.DownloadPreparer
24+
25+ mu sync.Mutex
26+ active map[uint32]cefDownloadState
27+}
28+
29+type cefDownloadState struct {
30+ filename string
31+ destination string
32+ finished bool
33+}
34+
35+type cefDownloadResponseAdapter struct {
36+ item purecef.DownloadItem
37+}
38+
39+func newDownloadHandler(
40+ downloadPath string,
41+ eventHandler port.DownloadEventHandler,
42+ preparer port.DownloadPreparer,
43+) *downloadHandler {
44+ if preparer == nil {
45+ panic("preparer is required")
46+ }
47+
48+ return &downloadHandler{
49+ downloadPath: downloadPath,
50+ eventHandler: eventHandler,
51+ preparer: preparer,
52+ active: make(map[uint32]cefDownloadState),
53+ }
54+}
55+
56+func (h *downloadHandler) canDownload(_ purecef.Browser, _ string, requestMethod string) bool {
57+ return requestMethod == "" || strings.EqualFold(requestMethod, http.MethodGet)
58+}
59+
60+func (h *downloadHandler) onBeforeDownload(
61+ ctx context.Context,
62+ _ purecef.Browser,
63+ downloadItem purecef.DownloadItem,
64+ suggestedName string,
65+ callback purecef.BeforeDownloadCallback,
66+) bool {
67+ if downloadItem == nil || callback == nil {
68+ return false
69+ }
70+
71+ log := logging.FromContext(ctx)
72+ downloadPath, eventHandler, preparer := h.snapshot()
73+ if err := os.MkdirAll(downloadPath, cefDownloadDirPerm); err != nil {
74+ log.Error().Err(err).Str("path", downloadPath).Msg("cef: failed to create download directory")
75+ callback.Cont("", 1)
76+ return true
77+ }
78+
79+ output := preparer.Execute(ctx, port.DownloadPrepareInput{
80+ SuggestedFilename: suggestedName,
81+ Response: &cefDownloadResponseAdapter{item: downloadItem},
82+ DownloadDir: downloadPath,
83+ })
84+
85+ h.mu.Lock()
86+ h.active[downloadItem.GetID()] = cefDownloadState{
87+ filename: output.Filename,
88+ destination: output.DestinationPath,
89+ }
90+ h.mu.Unlock()
91+
92+ callback.Cont(output.DestinationPath, 0)
93+
94+ if eventHandler != nil {
95+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
96+ Type: port.DownloadEventStarted,
97+ Filename: output.Filename,
98+ Destination: output.DestinationPath,
99+ })
100+ }
101+
102+ log.Info().
103+ Uint32("download_id", downloadItem.GetID()).
104+ Str("filename", output.Filename).
105+ Str("destination", output.DestinationPath).
106+ Msg("cef: download started")
107+
108+ return true
109+}
110+
111+func (h *downloadHandler) onDownloadUpdated(
112+ ctx context.Context,
113+ downloadItem purecef.DownloadItem,
114+ callback purecef.DownloadItemCallback,
115+) {
116+ _ = callback
117+ if downloadItem == nil {
118+ return
119+ }
120+
121+ log := logging.FromContext(ctx)
122+ id := downloadItem.GetID()
123+ state := h.currentState(id, downloadItem)
124+
125+ switch {
126+ case downloadItem.IsComplete():
127+ if !h.markFinished(id, state) {
128+ return
129+ }
130+ if h.eventHandler != nil {
131+ h.eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
132+ Type: port.DownloadEventFinished,
133+ Filename: state.filename,
134+ Destination: state.destination,
135+ })
136+ }
137+ log.Info().
138+ Uint32("download_id", id).
139+ Str("filename", state.filename).
140+ Msg("cef: download finished")
141+ case downloadItem.IsCanceled() || downloadItem.IsInterrupted():
142+ if !h.markFinished(id, state) {
143+ return
144+ }
145+ err := fmt.Errorf("download failed: %s", state.filename)
146+ if downloadItem.IsInterrupted() {
147+ err = fmt.Errorf("download interrupted: %s (%s)", state.filename, downloadItem.GetInterruptReason())
148+ } else if downloadItem.IsCanceled() {
149+ err = fmt.Errorf("download canceled: %s", state.filename)
150+ }
151+ if h.eventHandler != nil {
152+ h.eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
153+ Type: port.DownloadEventFailed,
154+ Filename: state.filename,
155+ Destination: state.destination,
156+ Error: err,
157+ })
158+ }
159+ log.Warn().
160+ Uint32("download_id", id).
161+ Err(err).
162+ Msg("cef: download failed")
163+ }
164+}
165+
166+func (h *downloadHandler) snapshot() (string, port.DownloadEventHandler, port.DownloadPreparer) {
167+ h.mu.Lock()
168+ defer h.mu.Unlock()
169+ return h.downloadPath, h.eventHandler, h.preparer
170+}
171+
172+func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cefDownloadState {
173+ h.mu.Lock()
174+ defer h.mu.Unlock()
175+
176+ state, ok := h.active[id]
177+ if !ok {
178+ state = cefDownloadState{}
179+ }
180+ if state.destination == "" {
181+ state.destination = item.GetFullPath()
182+ }
183+ if state.filename == "" {
184+ state.filename = downloadFilenameFromItem(item, state.destination)
185+ }
186+ h.active[id] = state
187+ return state
188+}
189+
190+func (h *downloadHandler) markFinished(id uint32, state cefDownloadState) bool {
191+ h.mu.Lock()
192+ defer h.mu.Unlock()
193+
194+ current, ok := h.active[id]
195+ if !ok {
196+ state.finished = true
197+ h.active[id] = state
198+ return true
199+ }
200+ if current.finished {
201+ return false
202+ }
203+ state.finished = true
204+ h.active[id] = state
205+ return true
206+}
207+
208+func downloadFilenameFromItem(item purecef.DownloadItem, destination string) string {
209+ if destination != "" {
210+ return downloadutil.ExtractFilenameFromDestination(destination)
211+ }
212+ if item == nil {
213+ return downloadutil.DefaultFilename
214+ }
215+ if suggested := item.GetSuggestedFileName(); suggested != "" {
216+ return downloadutil.SanitizeFilenameWithExtension(suggested, item.GetMimeType())
217+ }
218+ if uri := item.GetURL(); uri != "" {
219+ return downloadutil.ExtractFilenameFromURI(uri)
220+ }
221+ return downloadutil.DefaultFilename
222+}
223+
224+func (a *cefDownloadResponseAdapter) GetMimeType() string {
225+ if a == nil || a.item == nil {
226+ return ""
227+ }
228+ return a.item.GetMimeType()
229+}
230+
231+func (a *cefDownloadResponseAdapter) GetSuggestedFilename() string {
232+ if a == nil || a.item == nil {
233+ return ""
234+ }
235+ return a.item.GetSuggestedFileName()
236+}
237+
238+func (a *cefDownloadResponseAdapter) GetUri() string {
239+ if a == nil || a.item == nil {
240+ return ""
241+ }
242+ return a.item.GetURL()
243+}
@@ -0,0 +1,243 @@
1+package cef
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "os"
8+ "strings"
9+ "sync"
10+
11+ purecef "github.com/bnema/purego-cef/cef"
12+
13+ "github.com/bnema/dumber/internal/application/port"
14+ downloadutil "github.com/bnema/dumber/internal/domain/download"
15+ "github.com/bnema/dumber/internal/logging"
16+)
17+
18+const cefDownloadDirPerm = 0o755
19+
20+type downloadHandler struct {
21+ downloadPath string
22+ eventHandler port.DownloadEventHandler
23+ preparer port.DownloadPreparer
24+
25+ mu sync.Mutex
26+ active map[uint32]cefDownloadState
27+}
28+
29+type cefDownloadState struct {
30+ filename string
31+ destination string
32+ finished bool
33+}
34+
35+type cefDownloadResponseAdapter struct {
36+ item purecef.DownloadItem
37+}
38+
39+func newDownloadHandler(
40+ downloadPath string,
41+ eventHandler port.DownloadEventHandler,
42+ preparer port.DownloadPreparer,
43+) *downloadHandler {
44+ if preparer == nil {
45+ panic("preparer is required")
46+ }
47+
48+ return &downloadHandler{
49+ downloadPath: downloadPath,
50+ eventHandler: eventHandler,
51+ preparer: preparer,
52+ active: make(map[uint32]cefDownloadState),
53+ }
54+}
55+
56+func (h *downloadHandler) canDownload(_ purecef.Browser, _ string, requestMethod string) bool {
57+ return requestMethod == "" || strings.EqualFold(requestMethod, http.MethodGet)
58+}
59+
60+func (h *downloadHandler) onBeforeDownload(
61+ ctx context.Context,
62+ _ purecef.Browser,
63+ downloadItem purecef.DownloadItem,
64+ suggestedName string,
65+ callback purecef.BeforeDownloadCallback,
66+) bool {
67+ if downloadItem == nil || callback == nil {
68+ return false
69+ }
70+
71+ log := logging.FromContext(ctx)
72+ downloadPath, eventHandler, preparer := h.snapshot()
73+ if err := os.MkdirAll(downloadPath, cefDownloadDirPerm); err != nil {
74+ log.Error().Err(err).Str("path", downloadPath).Msg("cef: failed to create download directory")
75+ callback.Cont("", 1)
76+ return true
77+ }
78+
79+ output := preparer.Execute(ctx, port.DownloadPrepareInput{
80+ SuggestedFilename: suggestedName,
81+ Response: &cefDownloadResponseAdapter{item: downloadItem},
82+ DownloadDir: downloadPath,
83+ })
84+
85+ h.mu.Lock()
86+ h.active[downloadItem.GetID()] = cefDownloadState{
87+ filename: output.Filename,
88+ destination: output.DestinationPath,
89+ }
90+ h.mu.Unlock()
91+
92+ callback.Cont(output.DestinationPath, 0)
93+
94+ if eventHandler != nil {
95+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
96+ Type: port.DownloadEventStarted,
97+ Filename: output.Filename,
98+ Destination: output.DestinationPath,
99+ })
100+ }
101+
102+ log.Info().
103+ Uint32("download_id", downloadItem.GetID()).
104+ Str("filename", output.Filename).
105+ Str("destination", output.DestinationPath).
106+ Msg("cef: download started")
107+
108+ return true
109+}
110+
111+func (h *downloadHandler) onDownloadUpdated(
112+ ctx context.Context,
113+ downloadItem purecef.DownloadItem,
114+ callback purecef.DownloadItemCallback,
115+) {
116+ _ = callback
117+ if downloadItem == nil {
118+ return
119+ }
120+
121+ log := logging.FromContext(ctx)
122+ id := downloadItem.GetID()
123+ state := h.currentState(id, downloadItem)
124+
125+ switch {
126+ case downloadItem.IsComplete():
127+ if !h.markFinished(id, state) {
128+ return
129+ }
130+ if h.eventHandler != nil {
131+ h.eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
132+ Type: port.DownloadEventFinished,
133+ Filename: state.filename,
134+ Destination: state.destination,
135+ })
136+ }
137+ log.Info().
138+ Uint32("download_id", id).
139+ Str("filename", state.filename).
140+ Msg("cef: download finished")
141+ case downloadItem.IsCanceled() || downloadItem.IsInterrupted():
142+ if !h.markFinished(id, state) {
143+ return
144+ }
145+ err := fmt.Errorf("download failed: %s", state.filename)
146+ if downloadItem.IsInterrupted() {
147+ err = fmt.Errorf("download interrupted: %s (%s)", state.filename, downloadItem.GetInterruptReason())
148+ } else if downloadItem.IsCanceled() {
149+ err = fmt.Errorf("download canceled: %s", state.filename)
150+ }
151+ if h.eventHandler != nil {
152+ h.eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
153+ Type: port.DownloadEventFailed,
154+ Filename: state.filename,
155+ Destination: state.destination,
156+ Error: err,
157+ })
158+ }
159+ log.Warn().
160+ Uint32("download_id", id).
161+ Err(err).
162+ Msg("cef: download failed")
163+ }
164+}
165+
166+func (h *downloadHandler) snapshot() (string, port.DownloadEventHandler, port.DownloadPreparer) {
167+ h.mu.Lock()
168+ defer h.mu.Unlock()
169+ return h.downloadPath, h.eventHandler, h.preparer
170+}
171+
172+func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cefDownloadState {
173+ h.mu.Lock()
174+ defer h.mu.Unlock()
175+
176+ state, ok := h.active[id]
177+ if !ok {
178+ state = cefDownloadState{}
179+ }
180+ if state.destination == "" {
181+ state.destination = item.GetFullPath()
182+ }
183+ if state.filename == "" {
184+ state.filename = downloadFilenameFromItem(item, state.destination)
185+ }
186+ h.active[id] = state
187+ return state
188+}
189+
190+func (h *downloadHandler) markFinished(id uint32, state cefDownloadState) bool {
191+ h.mu.Lock()
192+ defer h.mu.Unlock()
193+
194+ current, ok := h.active[id]
195+ if !ok {
196+ state.finished = true
197+ h.active[id] = state
198+ return true
199+ }
200+ if current.finished {
201+ return false
202+ }
203+ state.finished = true
204+ h.active[id] = state
205+ return true
206+}
207+
208+func downloadFilenameFromItem(item purecef.DownloadItem, destination string) string {
209+ if destination != "" {
210+ return downloadutil.ExtractFilenameFromDestination(destination)
211+ }
212+ if item == nil {
213+ return downloadutil.DefaultFilename
214+ }
215+ if suggested := item.GetSuggestedFileName(); suggested != "" {
216+ return downloadutil.SanitizeFilenameWithExtension(suggested, item.GetMimeType())
217+ }
218+ if uri := item.GetURL(); uri != "" {
219+ return downloadutil.ExtractFilenameFromURI(uri)
220+ }
221+ return downloadutil.DefaultFilename
222+}
223+
224+func (a *cefDownloadResponseAdapter) GetMimeType() string {
225+ if a == nil || a.item == nil {
226+ return ""
227+ }
228+ return a.item.GetMimeType()
229+}
230+
231+func (a *cefDownloadResponseAdapter) GetSuggestedFilename() string {
232+ if a == nil || a.item == nil {
233+ return ""
234+ }
235+ return a.item.GetSuggestedFileName()
236+}
237+
238+func (a *cefDownloadResponseAdapter) GetUri() string {
239+ if a == nil || a.item == nil {
240+ return ""
241+ }
242+ return a.item.GetURL()
243+}
A internal/infrastructure/cef/download_handler_test.go +94 -0
@@ -0,0 +1,94 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/application/usecase"
12+)
13+
14+type stubDownloadItem struct {
15+ id uint32
16+ url string
17+ suggested string
18+ mimeType string
19+ fullPath string
20+ complete bool
21+ canceled bool
22+}
23+
24+func (s stubDownloadItem) IsValid() bool { return true }
25+func (s stubDownloadItem) IsInProgress() bool { return !s.complete && !s.canceled }
26+func (s stubDownloadItem) IsComplete() bool { return s.complete }
27+func (s stubDownloadItem) IsCanceled() bool { return s.canceled }
28+func (s stubDownloadItem) IsInterrupted() bool { return false }
29+func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason {
30+ return 0
31+}
32+func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 }
33+func (s stubDownloadItem) GetPercentComplete() int32 { return 0 }
34+func (s stubDownloadItem) GetTotalBytes() int64 { return 0 }
35+func (s stubDownloadItem) GetReceivedBytes() int64 { return 0 }
36+func (s stubDownloadItem) GetStartTime() uintptr { return 0 }
37+func (s stubDownloadItem) GetEndTime() uintptr { return 0 }
38+func (s stubDownloadItem) GetFullPath() string { return s.fullPath }
39+func (s stubDownloadItem) GetID() uint32 { return s.id }
40+func (s stubDownloadItem) GetURL() string { return s.url }
41+func (s stubDownloadItem) GetOriginalURL() string { return s.url }
42+func (s stubDownloadItem) GetSuggestedFileName() string { return s.suggested }
43+func (s stubDownloadItem) GetContentDisposition() string { return "" }
44+func (s stubDownloadItem) GetMimeType() string { return s.mimeType }
45+func (s stubDownloadItem) IsPaused() bool { return false }
46+
47+type stubBeforeDownloadCallback struct {
48+ path string
49+ showDialog int32
50+}
51+
52+func (s *stubBeforeDownloadCallback) Cont(downloadPath string, showDialog int32) {
53+ s.path = downloadPath
54+ s.showDialog = showDialog
55+}
56+
57+type captureDownloadEvents struct {
58+ events []port.DownloadEvent
59+}
60+
61+func (c *captureDownloadEvents) OnDownloadEvent(_ context.Context, event port.DownloadEvent) {
62+ c.events = append(c.events, event)
63+}
64+
65+func TestCEFDownloadHandlerLifecycle(t *testing.T) {
66+ ctx := context.Background()
67+ preparer := usecase.NewPrepareDownloadUseCase(nil)
68+ events := &captureDownloadEvents{}
69+ handler := newDownloadHandler("/tmp/downloads", events, preparer)
70+ callback := &stubBeforeDownloadCallback{}
71+
72+ item := stubDownloadItem{
73+ id: 7,
74+ url: "https://example.com/image.iso",
75+ suggested: "image.iso",
76+ mimeType: "application/x-iso9660-image",
77+ }
78+
79+ ok := handler.onBeforeDownload(ctx, nil, item, item.suggested, callback)
80+
81+ require.True(t, ok)
82+ require.Equal(t, "/tmp/downloads/image.iso", callback.path)
83+ require.Len(t, events.events, 1)
84+ require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
85+
86+ item.complete = true
87+ item.fullPath = callback.path
88+ handler.onDownloadUpdated(ctx, item, nil)
89+ handler.onDownloadUpdated(ctx, item, nil)
90+
91+ require.Len(t, events.events, 2)
92+ require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
93+ require.Equal(t, "image.iso", events.events[1].Filename)
94+}
@@ -0,0 +1,94 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/application/usecase"
12+)
13+
14+type stubDownloadItem struct {
15+ id uint32
16+ url string
17+ suggested string
18+ mimeType string
19+ fullPath string
20+ complete bool
21+ canceled bool
22+}
23+
24+func (s stubDownloadItem) IsValid() bool { return true }
25+func (s stubDownloadItem) IsInProgress() bool { return !s.complete && !s.canceled }
26+func (s stubDownloadItem) IsComplete() bool { return s.complete }
27+func (s stubDownloadItem) IsCanceled() bool { return s.canceled }
28+func (s stubDownloadItem) IsInterrupted() bool { return false }
29+func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason {
30+ return 0
31+}
32+func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 }
33+func (s stubDownloadItem) GetPercentComplete() int32 { return 0 }
34+func (s stubDownloadItem) GetTotalBytes() int64 { return 0 }
35+func (s stubDownloadItem) GetReceivedBytes() int64 { return 0 }
36+func (s stubDownloadItem) GetStartTime() uintptr { return 0 }
37+func (s stubDownloadItem) GetEndTime() uintptr { return 0 }
38+func (s stubDownloadItem) GetFullPath() string { return s.fullPath }
39+func (s stubDownloadItem) GetID() uint32 { return s.id }
40+func (s stubDownloadItem) GetURL() string { return s.url }
41+func (s stubDownloadItem) GetOriginalURL() string { return s.url }
42+func (s stubDownloadItem) GetSuggestedFileName() string { return s.suggested }
43+func (s stubDownloadItem) GetContentDisposition() string { return "" }
44+func (s stubDownloadItem) GetMimeType() string { return s.mimeType }
45+func (s stubDownloadItem) IsPaused() bool { return false }
46+
47+type stubBeforeDownloadCallback struct {
48+ path string
49+ showDialog int32
50+}
51+
52+func (s *stubBeforeDownloadCallback) Cont(downloadPath string, showDialog int32) {
53+ s.path = downloadPath
54+ s.showDialog = showDialog
55+}
56+
57+type captureDownloadEvents struct {
58+ events []port.DownloadEvent
59+}
60+
61+func (c *captureDownloadEvents) OnDownloadEvent(_ context.Context, event port.DownloadEvent) {
62+ c.events = append(c.events, event)
63+}
64+
65+func TestCEFDownloadHandlerLifecycle(t *testing.T) {
66+ ctx := context.Background()
67+ preparer := usecase.NewPrepareDownloadUseCase(nil)
68+ events := &captureDownloadEvents{}
69+ handler := newDownloadHandler("/tmp/downloads", events, preparer)
70+ callback := &stubBeforeDownloadCallback{}
71+
72+ item := stubDownloadItem{
73+ id: 7,
74+ url: "https://example.com/image.iso",
75+ suggested: "image.iso",
76+ mimeType: "application/x-iso9660-image",
77+ }
78+
79+ ok := handler.onBeforeDownload(ctx, nil, item, item.suggested, callback)
80+
81+ require.True(t, ok)
82+ require.Equal(t, "/tmp/downloads/image.iso", callback.path)
83+ require.Len(t, events.events, 1)
84+ require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
85+
86+ item.complete = true
87+ item.fullPath = callback.path
88+ handler.onDownloadUpdated(ctx, item, nil)
89+ handler.onDownloadUpdated(ctx, item, nil)
90+
91+ require.Len(t, events.events, 2)
92+ require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
93+ require.Equal(t, "image.iso", events.events[1].Filename)
94+}
M internal/infrastructure/cef/engine.go +19 -5
@@ -42,6 +42,8 @@ type Engine struct {
4242 onClipboardCopied func(textLen int)
4343 resolver port.ImageDataResolver
4444 mediaClassifier MediaClassifier
45+ downloadMu sync.RWMutex
46+ downloadHandler *downloadHandler
4547 alreadyRunningAppRelaunchMu sync.RWMutex
4648 alreadyRunningAppRelaunchHandler func(string)
4749
@@ -256,14 +258,17 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent
256258 return e.registerAccentHandlers(ctx, e.messageRouter, handler)
257259 }
258260
259-// ConfigureDownloads sets up download handling (Phase 1 stub).
261+// ConfigureDownloads sets up download handling for all CEF webviews.
260262 func (e *Engine) ConfigureDownloads(
261263 _ context.Context,
262- _ string,
263- _ port.DownloadEventHandler,
264- _ port.DownloadPreparer,
264+ downloadPath string,
265+ eventHandler port.DownloadEventHandler,
266+ preparer port.DownloadPreparer,
265267 ) error {
266- return ErrDownloadsUnsupported
268+ e.downloadMu.Lock()
269+ e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer)
270+ e.downloadMu.Unlock()
271+ return nil
267272 }
268273
269274 // OnToolkitReady is called after the UI toolkit has initialized.
@@ -447,6 +452,15 @@ func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce stri
447452 return valid
448453 }
449454
455+func (e *Engine) currentDownloadHandler() *downloadHandler {
456+ if e == nil {
457+ return nil
458+ }
459+ e.downloadMu.RLock()
460+ defer e.downloadMu.RUnlock()
461+ return e.downloadHandler
462+}
463+
450464 func (e *Engine) logTranscoderStartupState() {
451465 if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) {
452466 return
@@ -42,6 +42,8 @@ type Engine struct {
42 onClipboardCopied func(textLen int)42 onClipboardCopied func(textLen int)
43 resolver port.ImageDataResolver43 resolver port.ImageDataResolver
44 mediaClassifier MediaClassifier44 mediaClassifier MediaClassifier
45+ downloadMu sync.RWMutex
46+ downloadHandler *downloadHandler
45 alreadyRunningAppRelaunchMu sync.RWMutex47 alreadyRunningAppRelaunchMu sync.RWMutex
46 alreadyRunningAppRelaunchHandler func(string)48 alreadyRunningAppRelaunchHandler func(string)
47 49
@@ -256,14 +258,17 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent
256 return e.registerAccentHandlers(ctx, e.messageRouter, handler)258 return e.registerAccentHandlers(ctx, e.messageRouter, handler)
257 }259 }
258 260
259-// ConfigureDownloads sets up download handling (Phase 1 stub).261+// ConfigureDownloads sets up download handling for all CEF webviews.
260 func (e *Engine) ConfigureDownloads(262 func (e *Engine) ConfigureDownloads(
261 _ context.Context,263 _ context.Context,
262- _ string,264+ downloadPath string,
263- _ port.DownloadEventHandler,265+ eventHandler port.DownloadEventHandler,
264- _ port.DownloadPreparer,266+ preparer port.DownloadPreparer,
265 ) error {267 ) error {
266- return ErrDownloadsUnsupported268+ e.downloadMu.Lock()
269+ e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer)
270+ e.downloadMu.Unlock()
271+ return nil
267 }272 }
268 273
269 // OnToolkitReady is called after the UI toolkit has initialized.274 // OnToolkitReady is called after the UI toolkit has initialized.
@@ -447,6 +452,15 @@ func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce stri
447 return valid452 return valid
448 }453 }
449 454
455+func (e *Engine) currentDownloadHandler() *downloadHandler {
456+ if e == nil {
457+ return nil
458+ }
459+ e.downloadMu.RLock()
460+ defer e.downloadMu.RUnlock()
461+ return e.downloadHandler
462+}
463+
450 func (e *Engine) logTranscoderStartupState() {464 func (e *Engine) logTranscoderStartupState() {
451 if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) {465 if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) {
452 return466 return
M internal/infrastructure/cef/engine_contract_test.go +8 -3
@@ -5,13 +5,18 @@ 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{}
13- err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, nil)
14- require.ErrorIs(t, err, ErrDownloadsUnsupported)
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())
1520 }
1621
1722 func TestWebViewFactoryCreateRelated_ReturnsUnsupported(t *testing.T) {
@@ -5,13 +5,18 @@ 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{}
13- err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, nil)14+ preparer := usecase.NewPrepareDownloadUseCase(nil)
14- require.ErrorIs(t, err, ErrDownloadsUnsupported)15+
16+ err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, preparer)
17+
18+ require.NoError(t, err)
19+ require.NotNil(t, eng.currentDownloadHandler())
15 }20 }
16 21
17 func TestWebViewFactoryCreateRelated_ReturnsUnsupported(t *testing.T) {22 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 string, 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 string, 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 +15 -0
@@ -8,6 +8,7 @@ import (
88
99 purecef "github.com/bnema/purego-cef/cef"
1010 cefmocks "github.com/bnema/purego-cef/cef/mocks"
11+ "github.com/bnema/dumber/internal/application/usecase"
1112 "github.com/stretchr/testify/assert"
1213 "github.com/stretchr/testify/mock"
1314 "github.com/stretchr/testify/require"
@@ -374,6 +375,20 @@ func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) {
374375 require.Same(t, h, h.GetContextMenuHandler())
375376 }
376377
378+func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) {
379+ eng := &Engine{}
380+ require.NoError(t, eng.ConfigureDownloads(context.Background(), "/tmp/downloads", nil, usecase.NewPrepareDownloadUseCase(nil)))
381+
382+ h := &handlerSet{
383+ wv: &WebView{
384+ ctx: context.Background(),
385+ engine: eng,
386+ },
387+ }
388+
389+ require.Same(t, h, h.GetDownloadHandler())
390+}
391+
377392 func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) {
378393 wv := &WebView{
379394 id: 42,
@@ -8,6 +8,7 @@ import (
8 8
9 purecef "github.com/bnema/purego-cef/cef"9 purecef "github.com/bnema/purego-cef/cef"
10 cefmocks "github.com/bnema/purego-cef/cef/mocks"10 cefmocks "github.com/bnema/purego-cef/cef/mocks"
11+ "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"13 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
@@ -374,6 +375,20 @@ func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) {
374 require.Same(t, h, h.GetContextMenuHandler())375 require.Same(t, h, h.GetContextMenuHandler())
375 }376 }
376 377
378+func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) {
379+ eng := &Engine{}
380+ require.NoError(t, eng.ConfigureDownloads(context.Background(), "/tmp/downloads", nil, usecase.NewPrepareDownloadUseCase(nil)))
381+
382+ h := &handlerSet{
383+ wv: &WebView{
384+ ctx: context.Background(),
385+ engine: eng,
386+ },
387+ }
388+
389+ require.Same(t, h, h.GetDownloadHandler())
390+}
391+
377 func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) {392 func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) {
378 wv := &WebView{393 wv := &WebView{
379 id: 42,394 id: 42,
M internal/infrastructure/webkit/webview.go +2 -17
@@ -3,7 +3,6 @@ package webkit
33 import (
44 "context"
55 "fmt"
6- "net/url"
76 "os"
87 "strings"
98 "sync"
@@ -11,6 +10,7 @@ import (
1110 "time"
1211
1312 "github.com/bnema/dumber/internal/application/port"
13+ downloadutil "github.com/bnema/dumber/internal/domain/download"
1414 "github.com/bnema/dumber/internal/domain/entity"
1515 urlutil "github.com/bnema/dumber/internal/domain/url"
1616 "github.com/bnema/dumber/internal/infrastructure/desktop"
@@ -824,22 +824,7 @@ func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool {
824824 return false
825825 }
826826
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")
827+ return downloadutil.ShouldForceDownload(response.GetUri(), response.GetMimeType())
843828 }
844829
845830 func (wv *WebView) connectEnterFullscreenSignal() {
@@ -3,7 +3,6 @@ package webkit
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "net/url"
7 "os"6 "os"
8 "strings"7 "strings"
9 "sync"8 "sync"
@@ -11,6 +10,7 @@ import (
11 "time"10 "time"
12 11
13 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
13+ downloadutil "github.com/bnema/dumber/internal/domain/download"
14 "github.com/bnema/dumber/internal/domain/entity"14 "github.com/bnema/dumber/internal/domain/entity"
15 urlutil "github.com/bnema/dumber/internal/domain/url"15 urlutil "github.com/bnema/dumber/internal/domain/url"
16 "github.com/bnema/dumber/internal/infrastructure/desktop"16 "github.com/bnema/dumber/internal/infrastructure/desktop"
@@ -824,22 +824,7 @@ func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool {
824 return false824 return false
825 }825 }
826 826
827- mimeType := strings.ToLower(response.GetMimeType())827+ 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 }828 }
844 829
845 func (wv *WebView) connectEnterFullscreenSignal() {830 func (wv *WebView) connectEnterFullscreenSignal() {

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