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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 42 | 42 | onClipboardCopied func(textLen int) | |
| 43 | 43 | resolver port.ImageDataResolver | |
| 44 | 44 | mediaClassifier MediaClassifier | |
| 45 | + | downloadMu sync.RWMutex | |
| 46 | + | downloadHandler *downloadHandler | |
| 45 | 47 | alreadyRunningAppRelaunchMu sync.RWMutex | |
| 46 | 48 | alreadyRunningAppRelaunchHandler func(string) | |
| 47 | 49 | ||
| @@ -256,14 +258,17 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent | |||
| 256 | 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 | 262 | func (e *Engine) ConfigureDownloads( | |
| 261 | 263 | _ context.Context, | |
| 262 | - | _ string, | |
| 263 | - | _ port.DownloadEventHandler, | |
| 264 | - | _ port.DownloadPreparer, | |
| 264 | + | downloadPath string, | |
| 265 | + | eventHandler port.DownloadEventHandler, | |
| 266 | + | preparer port.DownloadPreparer, | |
| 265 | 267 | ) error { | |
| 266 | - | return ErrDownloadsUnsupported | |
| 268 | + | e.downloadMu.Lock() | |
| 269 | + | e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer) | |
| 270 | + | e.downloadMu.Unlock() | |
| 271 | + | return nil | |
| 267 | 272 | } | |
| 268 | 273 | ||
| 269 | 274 | // OnToolkitReady is called after the UI toolkit has initialized. | |
| @@ -447,6 +452,15 @@ func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce stri | |||
| 447 | 452 | 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 | 464 | func (e *Engine) logTranscoderStartupState() { | |
| 451 | 465 | if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) { | |
| 452 | 466 | return | |
| @@ -42,6 +42,8 @@ type Engine struct { | |||||
| 42 | onClipboardCopied func(textLen int) | 42 | onClipboardCopied func(textLen int) | ||
| 43 | resolver port.ImageDataResolver | 43 | resolver port.ImageDataResolver | ||
| 44 | mediaClassifier MediaClassifier | 44 | mediaClassifier MediaClassifier | ||
| 45 | + | downloadMu sync.RWMutex | |||
| 46 | + | downloadHandler *downloadHandler | |||
| 45 | alreadyRunningAppRelaunchMu sync.RWMutex | 47 | 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 ErrDownloadsUnsupported | 268 | + | 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 valid | 452 | 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 | return | 466 | return | ||
M internal/infrastructure/cef/engine_contract_test.go +8 -3
| @@ -5,13 +5,18 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | 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 | 13 | 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()) | |
| 15 | 20 | } | |
| 16 | 21 | ||
| 17 | 22 | 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 ( | |||
| 7 | 7 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| 10 | 11 | "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 11 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 13 | ) | |
| @@ -36,6 +37,7 @@ var ( | |||
| 36 | 37 | _ purecef.RequestHandler = (*handlerSet)(nil) | |
| 37 | 38 | _ purecef.AudioHandler = (*handlerSet)(nil) | |
| 38 | 39 | _ purecef.ContextMenuHandler = (*handlerSet)(nil) | |
| 40 | + | _ purecef.DownloadHandler = (*handlerSet)(nil) | |
| 39 | 41 | _ purecef.FindHandler = (*handlerSet)(nil) | |
| 40 | 42 | ) | |
| 41 | 43 | ||
| @@ -54,19 +56,24 @@ func (h *handlerSet) GetCommandHandler() purecef.CommandHandler { return | |||
| 54 | 56 | func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h } | |
| 55 | 57 | func (h *handlerSet) GetDialogHandler() purecef.DialogHandler { return nil } | |
| 56 | 58 | 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 } | |
| 70 | 77 | ||
| 71 | 78 | func (h *handlerSet) OnProcessMessageReceived( | |
| 72 | 79 | browser purecef.Browser, | |
| @@ -779,8 +786,79 @@ func (h *handlerSet) OnBeforeClose(_ purecef.Browser) { | |||
| 779 | 786 | // RequestHandler (11 methods) | |
| 780 | 787 | // =========================================================================== | |
| 781 | 788 | ||
| 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 | |
| 784 | 862 | } | |
| 785 | 863 | ||
| 786 | 864 | 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 false | 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 | |||
| 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 ( | |||
| 8 | 8 | ||
| 9 | 9 | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | 10 | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 11 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 12 | "github.com/stretchr/testify/assert" | |
| 12 | 13 | "github.com/stretchr/testify/mock" | |
| 13 | 14 | "github.com/stretchr/testify/require" | |
| @@ -374,6 +375,20 @@ func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) { | |||
| 374 | 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 | 392 | func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) { | |
| 378 | 393 | wv := &WebView{ | |
| 379 | 394 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "net/url" | |
| 7 | 6 | "os" | |
| 8 | 7 | "strings" | |
| 9 | 8 | "sync" | |
| @@ -11,6 +10,7 @@ import ( | |||
| 11 | 10 | "time" | |
| 12 | 11 | ||
| 13 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| 14 | 14 | "github.com/bnema/dumber/internal/domain/entity" | |
| 15 | 15 | urlutil "github.com/bnema/dumber/internal/domain/url" | |
| 16 | 16 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| @@ -824,22 +824,7 @@ func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool { | |||
| 824 | 824 | return false | |
| 825 | 825 | } | |
| 826 | 826 | ||
| 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()) | |
| 843 | 828 | } | |
| 844 | 829 | ||
| 845 | 830 | 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 false | 824 | 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() { | ||