Merge pull request #227 from bnema/feat/shared-download-handling-cef-webkit
feat(download): add shared download handling for CEF and WebKit
18 files · +1427 -150
M internal/application/port/download.go +9 -4
| @@ -8,6 +8,8 @@ type DownloadEventType int | |||
| 8 | 8 | const ( | |
| 9 | 9 | // DownloadEventStarted indicates a download has begun. | |
| 10 | 10 | DownloadEventStarted DownloadEventType = iota | |
| 11 | + | // DownloadEventProgress indicates download progress has advanced. | |
| 12 | + | DownloadEventProgress | |
| 11 | 13 | // DownloadEventFinished indicates a download completed successfully. | |
| 12 | 14 | DownloadEventFinished | |
| 13 | 15 | // DownloadEventFailed indicates a download failed. | |
| @@ -16,10 +18,13 @@ const ( | |||
| 16 | 18 | ||
| 17 | 19 | // DownloadEvent contains information about a download event. | |
| 18 | 20 | type DownloadEvent struct { | |
| 19 | - | Type DownloadEventType | |
| 20 | - | Filename string | |
| 21 | - | Destination string | |
| 22 | - | Error error // Set when Type is DownloadEventFailed | |
| 21 | + | Type DownloadEventType | |
| 22 | + | Filename string | |
| 23 | + | Destination string | |
| 24 | + | Progress float64 // Set when Type is DownloadEventProgress, normalized to 0.0-1.0. | |
| 25 | + | BytesReceived int64 // Best-effort received byte count for progress updates. | |
| 26 | + | BytesTotal int64 // Best-effort total byte count for progress updates when known. | |
| 27 | + | Error error // Set when Type is DownloadEventFailed | |
| 23 | 28 | } | |
| 24 | 29 | ||
| 25 | 30 | // DownloadEventHandler receives download event notifications. | |
| @@ -8,6 +8,8 @@ type DownloadEventType int | |||||
| 8 | const ( | 8 | const ( | ||
| 9 | // DownloadEventStarted indicates a download has begun. | 9 | // DownloadEventStarted indicates a download has begun. | ||
| 10 | DownloadEventStarted DownloadEventType = iota | 10 | DownloadEventStarted DownloadEventType = iota | ||
| 11 | + | // DownloadEventProgress indicates download progress has advanced. | |||
| 12 | + | DownloadEventProgress | |||
| 11 | // DownloadEventFinished indicates a download completed successfully. | 13 | // DownloadEventFinished indicates a download completed successfully. | ||
| 12 | DownloadEventFinished | 14 | DownloadEventFinished | ||
| 13 | // DownloadEventFailed indicates a download failed. | 15 | // DownloadEventFailed indicates a download failed. | ||
| @@ -16,10 +18,13 @@ const ( | |||||
| 16 | 18 | ||||
| 17 | // DownloadEvent contains information about a download event. | 19 | // DownloadEvent contains information about a download event. | ||
| 18 | type DownloadEvent struct { | 20 | type DownloadEvent struct { | ||
| 19 | - | Type DownloadEventType | 21 | + | Type DownloadEventType |
| 20 | - | Filename string | 22 | + | Filename string |
| 21 | - | Destination string | 23 | + | Destination string |
| 22 | - | Error error // Set when Type is DownloadEventFailed | 24 | + | Progress float64 // Set when Type is DownloadEventProgress, normalized to 0.0-1.0. |
| 25 | + | BytesReceived int64 // Best-effort received byte count for progress updates. | |||
| 26 | + | BytesTotal int64 // Best-effort total byte count for progress updates when known. | |||
| 27 | + | Error error // Set when Type is DownloadEventFailed | |||
| 23 | } | 28 | } | ||
| 24 | 29 | ||||
| 25 | // DownloadEventHandler receives download event notifications. | 30 | // DownloadEventHandler receives download event notifications. | ||
A internal/domain/download/policy.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | package download | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "mime" | |
| 5 | + | "net/url" | |
| 6 | + | "path" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var forcedDownloadExtensions = map[string]struct{}{ | |
| 11 | + | ".7z": {}, | |
| 12 | + | ".apk": {}, | |
| 13 | + | ".appimage": {}, | |
| 14 | + | ".bz2": {}, | |
| 15 | + | ".deb": {}, | |
| 16 | + | ".dmg": {}, | |
| 17 | + | ".exe": {}, | |
| 18 | + | ".gz": {}, | |
| 19 | + | ".img": {}, | |
| 20 | + | ".iso": {}, | |
| 21 | + | ".msi": {}, | |
| 22 | + | ".pdf": {}, | |
| 23 | + | ".pkg": {}, | |
| 24 | + | ".rar": {}, | |
| 25 | + | ".rpm": {}, | |
| 26 | + | ".tar": {}, | |
| 27 | + | ".tgz": {}, | |
| 28 | + | ".xz": {}, | |
| 29 | + | ".zip": {}, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | var forcedDownloadMIMETypes = map[string]struct{}{ | |
| 33 | + | "application/pdf": {}, | |
| 34 | + | "application/x-apple-diskimage": {}, | |
| 35 | + | "application/x-cd-image": {}, | |
| 36 | + | "application/x-iso9660-image": {}, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // ShouldForceDownload returns true when the resource should be downloaded | |
| 40 | + | // instead of navigated/rendered inline. | |
| 41 | + | func ShouldForceDownload(uri, mimeType string) bool { | |
| 42 | + | return ShouldForceDownloadForMIMEType(mimeType) || ShouldForceDownloadForURI(uri) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // ShouldForceDownloadForURI returns true when the URI path has a known | |
| 46 | + | // download-only extension. | |
| 47 | + | func ShouldForceDownloadForURI(uri string) bool { | |
| 48 | + | ext := strings.ToLower(path.Ext(uriPath(uri))) | |
| 49 | + | if ext == "" { | |
| 50 | + | return false | |
| 51 | + | } | |
| 52 | + | _, ok := forcedDownloadExtensions[ext] | |
| 53 | + | return ok | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // ShouldForceDownloadForMIMEType returns true when the MIME type maps to a | |
| 57 | + | // known download-only artifact. | |
| 58 | + | func ShouldForceDownloadForMIMEType(mimeType string) bool { | |
| 59 | + | if mimeType == "" { | |
| 60 | + | return false | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | mediaType := strings.ToLower(strings.TrimSpace(mimeType)) | |
| 64 | + | if parsed, _, err := mime.ParseMediaType(mediaType); err == nil { | |
| 65 | + | mediaType = parsed | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | _, ok := forcedDownloadMIMETypes[mediaType] | |
| 69 | + | return ok | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func uriPath(raw string) string { | |
| 73 | + | if raw == "" { | |
| 74 | + | return "" | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | parsed, err := url.Parse(raw) | |
| 78 | + | if err == nil && parsed.Path != "" { | |
| 79 | + | return parsed.Path | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | if cut := strings.IndexAny(raw, "?#"); cut >= 0 { | |
| 83 | + | return raw[:cut] | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | return raw | |
| 87 | + | } | |
| @@ -0,0 +1,87 @@ | |||||
| 1 | + | package download | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "mime" | |||
| 5 | + | "net/url" | |||
| 6 | + | "path" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | var forcedDownloadExtensions = map[string]struct{}{ | |||
| 11 | + | ".7z": {}, | |||
| 12 | + | ".apk": {}, | |||
| 13 | + | ".appimage": {}, | |||
| 14 | + | ".bz2": {}, | |||
| 15 | + | ".deb": {}, | |||
| 16 | + | ".dmg": {}, | |||
| 17 | + | ".exe": {}, | |||
| 18 | + | ".gz": {}, | |||
| 19 | + | ".img": {}, | |||
| 20 | + | ".iso": {}, | |||
| 21 | + | ".msi": {}, | |||
| 22 | + | ".pdf": {}, | |||
| 23 | + | ".pkg": {}, | |||
| 24 | + | ".rar": {}, | |||
| 25 | + | ".rpm": {}, | |||
| 26 | + | ".tar": {}, | |||
| 27 | + | ".tgz": {}, | |||
| 28 | + | ".xz": {}, | |||
| 29 | + | ".zip": {}, | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | var forcedDownloadMIMETypes = map[string]struct{}{ | |||
| 33 | + | "application/pdf": {}, | |||
| 34 | + | "application/x-apple-diskimage": {}, | |||
| 35 | + | "application/x-cd-image": {}, | |||
| 36 | + | "application/x-iso9660-image": {}, | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // ShouldForceDownload returns true when the resource should be downloaded | |||
| 40 | + | // instead of navigated/rendered inline. | |||
| 41 | + | func ShouldForceDownload(uri, mimeType string) bool { | |||
| 42 | + | return ShouldForceDownloadForMIMEType(mimeType) || ShouldForceDownloadForURI(uri) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // ShouldForceDownloadForURI returns true when the URI path has a known | |||
| 46 | + | // download-only extension. | |||
| 47 | + | func ShouldForceDownloadForURI(uri string) bool { | |||
| 48 | + | ext := strings.ToLower(path.Ext(uriPath(uri))) | |||
| 49 | + | if ext == "" { | |||
| 50 | + | return false | |||
| 51 | + | } | |||
| 52 | + | _, ok := forcedDownloadExtensions[ext] | |||
| 53 | + | return ok | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // ShouldForceDownloadForMIMEType returns true when the MIME type maps to a | |||
| 57 | + | // known download-only artifact. | |||
| 58 | + | func ShouldForceDownloadForMIMEType(mimeType string) bool { | |||
| 59 | + | if mimeType == "" { | |||
| 60 | + | return false | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | mediaType := strings.ToLower(strings.TrimSpace(mimeType)) | |||
| 64 | + | if parsed, _, err := mime.ParseMediaType(mediaType); err == nil { | |||
| 65 | + | mediaType = parsed | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | _, ok := forcedDownloadMIMETypes[mediaType] | |||
| 69 | + | return ok | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func uriPath(raw string) string { | |||
| 73 | + | if raw == "" { | |||
| 74 | + | return "" | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | parsed, err := url.Parse(raw) | |||
| 78 | + | if err == nil && parsed.Path != "" { | |||
| 79 | + | return parsed.Path | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | if cut := strings.IndexAny(raw, "?#"); cut >= 0 { | |||
| 83 | + | return raw[:cut] | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | return raw | |||
| 87 | + | } | |||
A internal/domain/download/policy_test.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package download | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestShouldForceDownloadForURI(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | uri string | |
| 9 | + | expected bool | |
| 10 | + | }{ | |
| 11 | + | {name: "pdf path", uri: "https://example.com/file.pdf", expected: true}, | |
| 12 | + | {name: "iso path", uri: "https://example.com/linux.iso?mirror=1", expected: true}, | |
| 13 | + | {name: "archive path", uri: "https://example.com/release.tar.gz", expected: true}, | |
| 14 | + | {name: "plain html", uri: "https://example.com/index.html", expected: false}, | |
| 15 | + | {name: "empty", uri: "", expected: false}, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | for _, tt := range tests { | |
| 19 | + | t.Run(tt.name, func(t *testing.T) { | |
| 20 | + | if got := ShouldForceDownloadForURI(tt.uri); got != tt.expected { | |
| 21 | + | t.Fatalf("ShouldForceDownloadForURI(%q) = %v, want %v", tt.uri, got, tt.expected) | |
| 22 | + | } | |
| 23 | + | }) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestShouldForceDownloadForMIMEType(t *testing.T) { | |
| 28 | + | tests := []struct { | |
| 29 | + | name string | |
| 30 | + | mimeType string | |
| 31 | + | expected bool | |
| 32 | + | }{ | |
| 33 | + | {name: "pdf", mimeType: "application/pdf", expected: true}, | |
| 34 | + | {name: "pdf with params", mimeType: "application/pdf; charset=binary", expected: true}, | |
| 35 | + | {name: "iso image", mimeType: "application/x-iso9660-image", expected: true}, | |
| 36 | + | {name: "html", mimeType: "text/html", expected: false}, | |
| 37 | + | {name: "empty", mimeType: "", expected: false}, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | for _, tt := range tests { | |
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |
| 42 | + | if got := ShouldForceDownloadForMIMEType(tt.mimeType); got != tt.expected { | |
| 43 | + | t.Fatalf("ShouldForceDownloadForMIMEType(%q) = %v, want %v", tt.mimeType, got, tt.expected) | |
| 44 | + | } | |
| 45 | + | }) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestShouldForceDownload(t *testing.T) { | |
| 50 | + | if !ShouldForceDownload("https://example.com/file.iso", "") { | |
| 51 | + | t.Fatal("expected URI extension to trigger forced download") | |
| 52 | + | } | |
| 53 | + | if !ShouldForceDownload("", "application/pdf") { | |
| 54 | + | t.Fatal("expected MIME type to trigger forced download") | |
| 55 | + | } | |
| 56 | + | if ShouldForceDownload("https://example.com/", "text/html") { | |
| 57 | + | t.Fatal("did not expect regular HTML to trigger forced download") | |
| 58 | + | } | |
| 59 | + | } | |
| @@ -0,0 +1,59 @@ | |||||
| 1 | + | package download | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestShouldForceDownloadForURI(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | uri string | |||
| 9 | + | expected bool | |||
| 10 | + | }{ | |||
| 11 | + | {name: "pdf path", uri: "https://example.com/file.pdf", expected: true}, | |||
| 12 | + | {name: "iso path", uri: "https://example.com/linux.iso?mirror=1", expected: true}, | |||
| 13 | + | {name: "archive path", uri: "https://example.com/release.tar.gz", expected: true}, | |||
| 14 | + | {name: "plain html", uri: "https://example.com/index.html", expected: false}, | |||
| 15 | + | {name: "empty", uri: "", expected: false}, | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | for _, tt := range tests { | |||
| 19 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 20 | + | if got := ShouldForceDownloadForURI(tt.uri); got != tt.expected { | |||
| 21 | + | t.Fatalf("ShouldForceDownloadForURI(%q) = %v, want %v", tt.uri, got, tt.expected) | |||
| 22 | + | } | |||
| 23 | + | }) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestShouldForceDownloadForMIMEType(t *testing.T) { | |||
| 28 | + | tests := []struct { | |||
| 29 | + | name string | |||
| 30 | + | mimeType string | |||
| 31 | + | expected bool | |||
| 32 | + | }{ | |||
| 33 | + | {name: "pdf", mimeType: "application/pdf", expected: true}, | |||
| 34 | + | {name: "pdf with params", mimeType: "application/pdf; charset=binary", expected: true}, | |||
| 35 | + | {name: "iso image", mimeType: "application/x-iso9660-image", expected: true}, | |||
| 36 | + | {name: "html", mimeType: "text/html", expected: false}, | |||
| 37 | + | {name: "empty", mimeType: "", expected: false}, | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | for _, tt := range tests { | |||
| 41 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 42 | + | if got := ShouldForceDownloadForMIMEType(tt.mimeType); got != tt.expected { | |||
| 43 | + | t.Fatalf("ShouldForceDownloadForMIMEType(%q) = %v, want %v", tt.mimeType, got, tt.expected) | |||
| 44 | + | } | |||
| 45 | + | }) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestShouldForceDownload(t *testing.T) { | |||
| 50 | + | if !ShouldForceDownload("https://example.com/file.iso", "") { | |||
| 51 | + | t.Fatal("expected URI extension to trigger forced download") | |||
| 52 | + | } | |||
| 53 | + | if !ShouldForceDownload("", "application/pdf") { | |||
| 54 | + | t.Fatal("expected MIME type to trigger forced download") | |||
| 55 | + | } | |||
| 56 | + | if ShouldForceDownload("https://example.com/", "text/html") { | |||
| 57 | + | t.Fatal("did not expect regular HTML to trigger forced download") | |||
| 58 | + | } | |||
| 59 | + | } | |||
A internal/infrastructure/cef/download_handler.go +323 -0
| @@ -0,0 +1,323 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sync" | |
| 7 | + | ||
| 8 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/downloadruntime" | |
| 13 | + | "github.com/bnema/dumber/internal/logging" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // maxFinishedEntries caps the finished-ID set. When the cap is reached the set | |
| 17 | + | // is cleared, accepting a tiny risk of a duplicate terminal event for very old | |
| 18 | + | // downloads. In practice download IDs are monotonically increasing uint32s and | |
| 19 | + | // no user session will approach this limit. | |
| 20 | + | const maxFinishedEntries = 1024 | |
| 21 | + | ||
| 22 | + | type downloadHandler struct { | |
| 23 | + | runtime *downloadruntime.Runtime | |
| 24 | + | mu sync.Mutex | |
| 25 | + | active map[uint32]cefDownloadState | |
| 26 | + | finished map[uint32]struct{} // IDs that already emitted a terminal event | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | type cefDownloadState struct { | |
| 30 | + | filename string | |
| 31 | + | destination string | |
| 32 | + | lastProgressPercent int32 | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | type cefDownloadInterruptError struct { | |
| 36 | + | filename string | |
| 37 | + | reason purecef.DownloadInterruptReason | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (e cefDownloadInterruptError) Error() string { | |
| 41 | + | return fmt.Sprintf( | |
| 42 | + | "download interrupted: %s (code=%d, reason=%s)", | |
| 43 | + | e.filename, | |
| 44 | + | e.reason, | |
| 45 | + | downloadInterruptReasonLabel(e.reason), | |
| 46 | + | ) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (e cefDownloadInterruptError) DownloadErrorCode() int { | |
| 50 | + | return int(e.reason) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (e cefDownloadInterruptError) DownloadErrorReason() string { | |
| 54 | + | return downloadInterruptReasonLabel(e.reason) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | type cefDownloadResponseAdapter struct { | |
| 58 | + | item purecef.DownloadItem | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | var downloadInterruptReasonLabels = map[purecef.DownloadInterruptReason]string{ | |
| 62 | + | purecef.DownloadInterruptReasonNone: "none", | |
| 63 | + | purecef.DownloadInterruptReasonFileFailed: "file_failed", | |
| 64 | + | purecef.DownloadInterruptReasonFileAccessDenied: "file_access_denied", | |
| 65 | + | purecef.DownloadInterruptReasonFileNoSpace: "file_no_space", | |
| 66 | + | purecef.DownloadInterruptReasonFileNameTooLong: "file_name_too_long", | |
| 67 | + | purecef.DownloadInterruptReasonFileTooLarge: "file_too_large", | |
| 68 | + | purecef.DownloadInterruptReasonFileVirusInfected: "file_virus_infected", | |
| 69 | + | purecef.DownloadInterruptReasonFileTransientError: "file_transient_error", | |
| 70 | + | purecef.DownloadInterruptReasonFileBlocked: "file_blocked", | |
| 71 | + | purecef.DownloadInterruptReasonFileSecurityCheckFailed: "file_security_check_failed", | |
| 72 | + | purecef.DownloadInterruptReasonFileTooShort: "file_too_short", | |
| 73 | + | purecef.DownloadInterruptReasonFileHashMismatch: "file_hash_mismatch", | |
| 74 | + | purecef.DownloadInterruptReasonFileSameAsSource: "file_same_as_source", | |
| 75 | + | purecef.DownloadInterruptReasonNetworkFailed: "network_failed", | |
| 76 | + | purecef.DownloadInterruptReasonNetworkTimeout: "network_timeout", | |
| 77 | + | purecef.DownloadInterruptReasonNetworkDisconnected: "network_disconnected", | |
| 78 | + | purecef.DownloadInterruptReasonNetworkServerDown: "network_server_down", | |
| 79 | + | purecef.DownloadInterruptReasonNetworkInvalidRequest: "network_invalid_request", | |
| 80 | + | purecef.DownloadInterruptReasonServerFailed: "server_failed", | |
| 81 | + | purecef.DownloadInterruptReasonServerNoRange: "server_no_range", | |
| 82 | + | purecef.DownloadInterruptReasonServerBadContent: "server_bad_content", | |
| 83 | + | purecef.DownloadInterruptReasonServerUnauthorized: "server_unauthorized", | |
| 84 | + | purecef.DownloadInterruptReasonServerCertProblem: "server_cert_problem", | |
| 85 | + | purecef.DownloadInterruptReasonServerForbidden: "server_forbidden", | |
| 86 | + | purecef.DownloadInterruptReasonServerUnreachable: "server_unreachable", | |
| 87 | + | purecef.DownloadInterruptReasonServerContentLengthMismatch: "server_content_length_mismatch", | |
| 88 | + | purecef.DownloadInterruptReasonServerCrossOriginRedirect: "server_cross_origin_redirect", | |
| 89 | + | purecef.DownloadInterruptReasonUserCanceled: "user_canceled", | |
| 90 | + | purecef.DownloadInterruptReasonUserShutdown: "user_shutdown", | |
| 91 | + | purecef.DownloadInterruptReasonCrash: "crash", | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func downloadInterruptReasonLabel(reason purecef.DownloadInterruptReason) string { | |
| 95 | + | if label, ok := downloadInterruptReasonLabels[reason]; ok { | |
| 96 | + | return label | |
| 97 | + | } | |
| 98 | + | return "unknown" | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func newDownloadHandler( | |
| 102 | + | downloadPath string, | |
| 103 | + | eventHandler port.DownloadEventHandler, | |
| 104 | + | preparer port.DownloadPreparer, | |
| 105 | + | ) *downloadHandler { | |
| 106 | + | return &downloadHandler{ | |
| 107 | + | runtime: downloadruntime.New(downloadPath, eventHandler, preparer), | |
| 108 | + | active: make(map[uint32]cefDownloadState), | |
| 109 | + | finished: make(map[uint32]struct{}), | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (h *downloadHandler) canDownload(_ purecef.Browser, _, _ string) bool { | |
| 114 | + | return true | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func (h *downloadHandler) onBeforeDownload( | |
| 118 | + | ctx context.Context, | |
| 119 | + | _ purecef.Browser, | |
| 120 | + | downloadItem purecef.DownloadItem, | |
| 121 | + | suggestedName string, | |
| 122 | + | callback purecef.BeforeDownloadCallback, | |
| 123 | + | ) bool { | |
| 124 | + | if downloadItem == nil || callback == nil { | |
| 125 | + | return false | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | log := logging.FromContext(ctx) | |
| 129 | + | output, err := h.runtime.ResolveDestination(ctx, suggestedName, &cefDownloadResponseAdapter{item: downloadItem}) | |
| 130 | + | if err != nil { | |
| 131 | + | log.Error().Err(err).Msg("cef: failed to prepare download destination") | |
| 132 | + | callback.Cont("", 1) | |
| 133 | + | return true | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | h.mu.Lock() | |
| 137 | + | h.active[downloadItem.GetID()] = cefDownloadState{ | |
| 138 | + | filename: output.Filename, | |
| 139 | + | destination: output.DestinationPath, | |
| 140 | + | lastProgressPercent: -1, | |
| 141 | + | } | |
| 142 | + | h.mu.Unlock() | |
| 143 | + | ||
| 144 | + | callback.Cont(output.DestinationPath, 0) | |
| 145 | + | h.runtime.EmitStarted(ctx, output) | |
| 146 | + | ||
| 147 | + | return true | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func (h *downloadHandler) onDownloadUpdated( | |
| 151 | + | ctx context.Context, | |
| 152 | + | downloadItem purecef.DownloadItem, | |
| 153 | + | callback purecef.DownloadItemCallback, | |
| 154 | + | ) { | |
| 155 | + | _ = callback | |
| 156 | + | if downloadItem == nil { | |
| 157 | + | return | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | id := downloadItem.GetID() | |
| 161 | + | ||
| 162 | + | // Fast path: skip already-finished downloads to avoid re-populating active. | |
| 163 | + | h.mu.Lock() | |
| 164 | + | _, done := h.finished[id] | |
| 165 | + | h.mu.Unlock() | |
| 166 | + | if done { | |
| 167 | + | return | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | state := h.currentState(id, downloadItem) | |
| 171 | + | h.emitProgressIfNeeded(ctx, id, downloadItem, state) | |
| 172 | + | ||
| 173 | + | switch { | |
| 174 | + | case downloadItem.IsComplete(): | |
| 175 | + | if !h.markFinished(id) { | |
| 176 | + | return | |
| 177 | + | } | |
| 178 | + | h.runtime.EmitFinished(ctx, state.filename, state.destination) | |
| 179 | + | case downloadItem.IsCanceled() || downloadItem.IsInterrupted(): | |
| 180 | + | if !h.markFinished(id) { | |
| 181 | + | return | |
| 182 | + | } | |
| 183 | + | var err error | |
| 184 | + | if downloadItem.IsInterrupted() { | |
| 185 | + | err = cefDownloadInterruptError{ | |
| 186 | + | filename: state.filename, | |
| 187 | + | reason: downloadItem.GetInterruptReason(), | |
| 188 | + | } | |
| 189 | + | } else { | |
| 190 | + | err = fmt.Errorf("download canceled: %s", state.filename) | |
| 191 | + | } | |
| 192 | + | h.runtime.EmitFailed(ctx, state.filename, state.destination, err) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cefDownloadState { | |
| 197 | + | h.mu.Lock() | |
| 198 | + | defer h.mu.Unlock() | |
| 199 | + | ||
| 200 | + | state, exists := h.active[id] | |
| 201 | + | if !exists { | |
| 202 | + | state = cefDownloadState{} | |
| 203 | + | } | |
| 204 | + | updated := false | |
| 205 | + | if state.destination == "" { | |
| 206 | + | state.destination = item.GetFullPath() | |
| 207 | + | updated = true | |
| 208 | + | } | |
| 209 | + | if state.filename == "" { | |
| 210 | + | state.filename = downloadFilenameFromItem(item, state.destination) | |
| 211 | + | updated = true | |
| 212 | + | } | |
| 213 | + | if exists && updated { | |
| 214 | + | h.active[id] = state | |
| 215 | + | } | |
| 216 | + | return state | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func (h *downloadHandler) emitProgressIfNeeded( | |
| 220 | + | ctx context.Context, | |
| 221 | + | id uint32, | |
| 222 | + | item purecef.DownloadItem, | |
| 223 | + | state cefDownloadState, | |
| 224 | + | ) { | |
| 225 | + | percent, ok := downloadProgressPercentFromItem(item) | |
| 226 | + | if !ok { | |
| 227 | + | return | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | h.mu.Lock() | |
| 231 | + | current, exists := h.active[id] | |
| 232 | + | if !exists { | |
| 233 | + | current = state | |
| 234 | + | } | |
| 235 | + | if current.lastProgressPercent == percent { | |
| 236 | + | h.mu.Unlock() | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | current.lastProgressPercent = percent | |
| 240 | + | h.active[id] = current | |
| 241 | + | h.mu.Unlock() | |
| 242 | + | ||
| 243 | + | h.runtime.EmitProgress( | |
| 244 | + | ctx, | |
| 245 | + | current.filename, | |
| 246 | + | current.destination, | |
| 247 | + | float64(percent)/100, | |
| 248 | + | item.GetReceivedBytes(), | |
| 249 | + | item.GetTotalBytes(), | |
| 250 | + | ) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func downloadProgressPercentFromItem(item purecef.DownloadItem) (int32, bool) { | |
| 254 | + | if item == nil || item.IsComplete() || item.IsCanceled() || item.IsInterrupted() { | |
| 255 | + | return 0, false | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | percent := item.GetPercentComplete() | |
| 259 | + | if percent < 0 { | |
| 260 | + | total := item.GetTotalBytes() | |
| 261 | + | received := item.GetReceivedBytes() | |
| 262 | + | if total <= 0 || received <= 0 { | |
| 263 | + | return 0, false | |
| 264 | + | } | |
| 265 | + | percent = int32((received * 100) / total) | |
| 266 | + | } | |
| 267 | + | if percent <= 0 || percent >= 100 { | |
| 268 | + | return 0, false | |
| 269 | + | } | |
| 270 | + | return percent, true | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func (h *downloadHandler) markFinished(id uint32) bool { | |
| 274 | + | h.mu.Lock() | |
| 275 | + | defer h.mu.Unlock() | |
| 276 | + | ||
| 277 | + | delete(h.active, id) | |
| 278 | + | if _, seen := h.finished[id]; seen { | |
| 279 | + | return false | |
| 280 | + | } | |
| 281 | + | if len(h.finished) >= maxFinishedEntries { | |
| 282 | + | clear(h.finished) | |
| 283 | + | } | |
| 284 | + | h.finished[id] = struct{}{} | |
| 285 | + | return true | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func downloadFilenameFromItem(item purecef.DownloadItem, destination string) string { | |
| 289 | + | if destination != "" { | |
| 290 | + | return downloadutil.ExtractFilenameFromDestination(destination) | |
| 291 | + | } | |
| 292 | + | if item == nil { | |
| 293 | + | return downloadutil.DefaultFilename | |
| 294 | + | } | |
| 295 | + | if suggested := item.GetSuggestedFileName(); suggested != "" { | |
| 296 | + | return downloadutil.SanitizeFilenameWithExtension(suggested, item.GetMimeType()) | |
| 297 | + | } | |
| 298 | + | if uri := item.GetURL(); uri != "" { | |
| 299 | + | return downloadutil.ExtractFilenameFromURI(uri) | |
| 300 | + | } | |
| 301 | + | return downloadutil.DefaultFilename | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func (a *cefDownloadResponseAdapter) GetMimeType() string { | |
| 305 | + | if a == nil || a.item == nil { | |
| 306 | + | return "" | |
| 307 | + | } | |
| 308 | + | return a.item.GetMimeType() | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | func (a *cefDownloadResponseAdapter) GetSuggestedFilename() string { | |
| 312 | + | if a == nil || a.item == nil { | |
| 313 | + | return "" | |
| 314 | + | } | |
| 315 | + | return a.item.GetSuggestedFileName() | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func (a *cefDownloadResponseAdapter) GetUri() string { | |
| 319 | + | if a == nil || a.item == nil { | |
| 320 | + | return "" | |
| 321 | + | } | |
| 322 | + | return a.item.GetURL() | |
| 323 | + | } | |
| @@ -0,0 +1,323 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "sync" | |||
| 7 | + | ||||
| 8 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 11 | + | downloadutil "github.com/bnema/dumber/internal/domain/download" | |||
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/downloadruntime" | |||
| 13 | + | "github.com/bnema/dumber/internal/logging" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // maxFinishedEntries caps the finished-ID set. When the cap is reached the set | |||
| 17 | + | // is cleared, accepting a tiny risk of a duplicate terminal event for very old | |||
| 18 | + | // downloads. In practice download IDs are monotonically increasing uint32s and | |||
| 19 | + | // no user session will approach this limit. | |||
| 20 | + | const maxFinishedEntries = 1024 | |||
| 21 | + | ||||
| 22 | + | type downloadHandler struct { | |||
| 23 | + | runtime *downloadruntime.Runtime | |||
| 24 | + | mu sync.Mutex | |||
| 25 | + | active map[uint32]cefDownloadState | |||
| 26 | + | finished map[uint32]struct{} // IDs that already emitted a terminal event | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | type cefDownloadState struct { | |||
| 30 | + | filename string | |||
| 31 | + | destination string | |||
| 32 | + | lastProgressPercent int32 | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | type cefDownloadInterruptError struct { | |||
| 36 | + | filename string | |||
| 37 | + | reason purecef.DownloadInterruptReason | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (e cefDownloadInterruptError) Error() string { | |||
| 41 | + | return fmt.Sprintf( | |||
| 42 | + | "download interrupted: %s (code=%d, reason=%s)", | |||
| 43 | + | e.filename, | |||
| 44 | + | e.reason, | |||
| 45 | + | downloadInterruptReasonLabel(e.reason), | |||
| 46 | + | ) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (e cefDownloadInterruptError) DownloadErrorCode() int { | |||
| 50 | + | return int(e.reason) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func (e cefDownloadInterruptError) DownloadErrorReason() string { | |||
| 54 | + | return downloadInterruptReasonLabel(e.reason) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | type cefDownloadResponseAdapter struct { | |||
| 58 | + | item purecef.DownloadItem | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | var downloadInterruptReasonLabels = map[purecef.DownloadInterruptReason]string{ | |||
| 62 | + | purecef.DownloadInterruptReasonNone: "none", | |||
| 63 | + | purecef.DownloadInterruptReasonFileFailed: "file_failed", | |||
| 64 | + | purecef.DownloadInterruptReasonFileAccessDenied: "file_access_denied", | |||
| 65 | + | purecef.DownloadInterruptReasonFileNoSpace: "file_no_space", | |||
| 66 | + | purecef.DownloadInterruptReasonFileNameTooLong: "file_name_too_long", | |||
| 67 | + | purecef.DownloadInterruptReasonFileTooLarge: "file_too_large", | |||
| 68 | + | purecef.DownloadInterruptReasonFileVirusInfected: "file_virus_infected", | |||
| 69 | + | purecef.DownloadInterruptReasonFileTransientError: "file_transient_error", | |||
| 70 | + | purecef.DownloadInterruptReasonFileBlocked: "file_blocked", | |||
| 71 | + | purecef.DownloadInterruptReasonFileSecurityCheckFailed: "file_security_check_failed", | |||
| 72 | + | purecef.DownloadInterruptReasonFileTooShort: "file_too_short", | |||
| 73 | + | purecef.DownloadInterruptReasonFileHashMismatch: "file_hash_mismatch", | |||
| 74 | + | purecef.DownloadInterruptReasonFileSameAsSource: "file_same_as_source", | |||
| 75 | + | purecef.DownloadInterruptReasonNetworkFailed: "network_failed", | |||
| 76 | + | purecef.DownloadInterruptReasonNetworkTimeout: "network_timeout", | |||
| 77 | + | purecef.DownloadInterruptReasonNetworkDisconnected: "network_disconnected", | |||
| 78 | + | purecef.DownloadInterruptReasonNetworkServerDown: "network_server_down", | |||
| 79 | + | purecef.DownloadInterruptReasonNetworkInvalidRequest: "network_invalid_request", | |||
| 80 | + | purecef.DownloadInterruptReasonServerFailed: "server_failed", | |||
| 81 | + | purecef.DownloadInterruptReasonServerNoRange: "server_no_range", | |||
| 82 | + | purecef.DownloadInterruptReasonServerBadContent: "server_bad_content", | |||
| 83 | + | purecef.DownloadInterruptReasonServerUnauthorized: "server_unauthorized", | |||
| 84 | + | purecef.DownloadInterruptReasonServerCertProblem: "server_cert_problem", | |||
| 85 | + | purecef.DownloadInterruptReasonServerForbidden: "server_forbidden", | |||
| 86 | + | purecef.DownloadInterruptReasonServerUnreachable: "server_unreachable", | |||
| 87 | + | purecef.DownloadInterruptReasonServerContentLengthMismatch: "server_content_length_mismatch", | |||
| 88 | + | purecef.DownloadInterruptReasonServerCrossOriginRedirect: "server_cross_origin_redirect", | |||
| 89 | + | purecef.DownloadInterruptReasonUserCanceled: "user_canceled", | |||
| 90 | + | purecef.DownloadInterruptReasonUserShutdown: "user_shutdown", | |||
| 91 | + | purecef.DownloadInterruptReasonCrash: "crash", | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func downloadInterruptReasonLabel(reason purecef.DownloadInterruptReason) string { | |||
| 95 | + | if label, ok := downloadInterruptReasonLabels[reason]; ok { | |||
| 96 | + | return label | |||
| 97 | + | } | |||
| 98 | + | return "unknown" | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func newDownloadHandler( | |||
| 102 | + | downloadPath string, | |||
| 103 | + | eventHandler port.DownloadEventHandler, | |||
| 104 | + | preparer port.DownloadPreparer, | |||
| 105 | + | ) *downloadHandler { | |||
| 106 | + | return &downloadHandler{ | |||
| 107 | + | runtime: downloadruntime.New(downloadPath, eventHandler, preparer), | |||
| 108 | + | active: make(map[uint32]cefDownloadState), | |||
| 109 | + | finished: make(map[uint32]struct{}), | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (h *downloadHandler) canDownload(_ purecef.Browser, _, _ string) bool { | |||
| 114 | + | return true | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func (h *downloadHandler) onBeforeDownload( | |||
| 118 | + | ctx context.Context, | |||
| 119 | + | _ purecef.Browser, | |||
| 120 | + | downloadItem purecef.DownloadItem, | |||
| 121 | + | suggestedName string, | |||
| 122 | + | callback purecef.BeforeDownloadCallback, | |||
| 123 | + | ) bool { | |||
| 124 | + | if downloadItem == nil || callback == nil { | |||
| 125 | + | return false | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | log := logging.FromContext(ctx) | |||
| 129 | + | output, err := h.runtime.ResolveDestination(ctx, suggestedName, &cefDownloadResponseAdapter{item: downloadItem}) | |||
| 130 | + | if err != nil { | |||
| 131 | + | log.Error().Err(err).Msg("cef: failed to prepare download destination") | |||
| 132 | + | callback.Cont("", 1) | |||
| 133 | + | return true | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | h.mu.Lock() | |||
| 137 | + | h.active[downloadItem.GetID()] = cefDownloadState{ | |||
| 138 | + | filename: output.Filename, | |||
| 139 | + | destination: output.DestinationPath, | |||
| 140 | + | lastProgressPercent: -1, | |||
| 141 | + | } | |||
| 142 | + | h.mu.Unlock() | |||
| 143 | + | ||||
| 144 | + | callback.Cont(output.DestinationPath, 0) | |||
| 145 | + | h.runtime.EmitStarted(ctx, output) | |||
| 146 | + | ||||
| 147 | + | return true | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | func (h *downloadHandler) onDownloadUpdated( | |||
| 151 | + | ctx context.Context, | |||
| 152 | + | downloadItem purecef.DownloadItem, | |||
| 153 | + | callback purecef.DownloadItemCallback, | |||
| 154 | + | ) { | |||
| 155 | + | _ = callback | |||
| 156 | + | if downloadItem == nil { | |||
| 157 | + | return | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | id := downloadItem.GetID() | |||
| 161 | + | ||||
| 162 | + | // Fast path: skip already-finished downloads to avoid re-populating active. | |||
| 163 | + | h.mu.Lock() | |||
| 164 | + | _, done := h.finished[id] | |||
| 165 | + | h.mu.Unlock() | |||
| 166 | + | if done { | |||
| 167 | + | return | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | state := h.currentState(id, downloadItem) | |||
| 171 | + | h.emitProgressIfNeeded(ctx, id, downloadItem, state) | |||
| 172 | + | ||||
| 173 | + | switch { | |||
| 174 | + | case downloadItem.IsComplete(): | |||
| 175 | + | if !h.markFinished(id) { | |||
| 176 | + | return | |||
| 177 | + | } | |||
| 178 | + | h.runtime.EmitFinished(ctx, state.filename, state.destination) | |||
| 179 | + | case downloadItem.IsCanceled() || downloadItem.IsInterrupted(): | |||
| 180 | + | if !h.markFinished(id) { | |||
| 181 | + | return | |||
| 182 | + | } | |||
| 183 | + | var err error | |||
| 184 | + | if downloadItem.IsInterrupted() { | |||
| 185 | + | err = cefDownloadInterruptError{ | |||
| 186 | + | filename: state.filename, | |||
| 187 | + | reason: downloadItem.GetInterruptReason(), | |||
| 188 | + | } | |||
| 189 | + | } else { | |||
| 190 | + | err = fmt.Errorf("download canceled: %s", state.filename) | |||
| 191 | + | } | |||
| 192 | + | h.runtime.EmitFailed(ctx, state.filename, state.destination, err) | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cefDownloadState { | |||
| 197 | + | h.mu.Lock() | |||
| 198 | + | defer h.mu.Unlock() | |||
| 199 | + | ||||
| 200 | + | state, exists := h.active[id] | |||
| 201 | + | if !exists { | |||
| 202 | + | state = cefDownloadState{} | |||
| 203 | + | } | |||
| 204 | + | updated := false | |||
| 205 | + | if state.destination == "" { | |||
| 206 | + | state.destination = item.GetFullPath() | |||
| 207 | + | updated = true | |||
| 208 | + | } | |||
| 209 | + | if state.filename == "" { | |||
| 210 | + | state.filename = downloadFilenameFromItem(item, state.destination) | |||
| 211 | + | updated = true | |||
| 212 | + | } | |||
| 213 | + | if exists && updated { | |||
| 214 | + | h.active[id] = state | |||
| 215 | + | } | |||
| 216 | + | return state | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func (h *downloadHandler) emitProgressIfNeeded( | |||
| 220 | + | ctx context.Context, | |||
| 221 | + | id uint32, | |||
| 222 | + | item purecef.DownloadItem, | |||
| 223 | + | state cefDownloadState, | |||
| 224 | + | ) { | |||
| 225 | + | percent, ok := downloadProgressPercentFromItem(item) | |||
| 226 | + | if !ok { | |||
| 227 | + | return | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | h.mu.Lock() | |||
| 231 | + | current, exists := h.active[id] | |||
| 232 | + | if !exists { | |||
| 233 | + | current = state | |||
| 234 | + | } | |||
| 235 | + | if current.lastProgressPercent == percent { | |||
| 236 | + | h.mu.Unlock() | |||
| 237 | + | return | |||
| 238 | + | } | |||
| 239 | + | current.lastProgressPercent = percent | |||
| 240 | + | h.active[id] = current | |||
| 241 | + | h.mu.Unlock() | |||
| 242 | + | ||||
| 243 | + | h.runtime.EmitProgress( | |||
| 244 | + | ctx, | |||
| 245 | + | current.filename, | |||
| 246 | + | current.destination, | |||
| 247 | + | float64(percent)/100, | |||
| 248 | + | item.GetReceivedBytes(), | |||
| 249 | + | item.GetTotalBytes(), | |||
| 250 | + | ) | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func downloadProgressPercentFromItem(item purecef.DownloadItem) (int32, bool) { | |||
| 254 | + | if item == nil || item.IsComplete() || item.IsCanceled() || item.IsInterrupted() { | |||
| 255 | + | return 0, false | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | percent := item.GetPercentComplete() | |||
| 259 | + | if percent < 0 { | |||
| 260 | + | total := item.GetTotalBytes() | |||
| 261 | + | received := item.GetReceivedBytes() | |||
| 262 | + | if total <= 0 || received <= 0 { | |||
| 263 | + | return 0, false | |||
| 264 | + | } | |||
| 265 | + | percent = int32((received * 100) / total) | |||
| 266 | + | } | |||
| 267 | + | if percent <= 0 || percent >= 100 { | |||
| 268 | + | return 0, false | |||
| 269 | + | } | |||
| 270 | + | return percent, true | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func (h *downloadHandler) markFinished(id uint32) bool { | |||
| 274 | + | h.mu.Lock() | |||
| 275 | + | defer h.mu.Unlock() | |||
| 276 | + | ||||
| 277 | + | delete(h.active, id) | |||
| 278 | + | if _, seen := h.finished[id]; seen { | |||
| 279 | + | return false | |||
| 280 | + | } | |||
| 281 | + | if len(h.finished) >= maxFinishedEntries { | |||
| 282 | + | clear(h.finished) | |||
| 283 | + | } | |||
| 284 | + | h.finished[id] = struct{}{} | |||
| 285 | + | return true | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func downloadFilenameFromItem(item purecef.DownloadItem, destination string) string { | |||
| 289 | + | if destination != "" { | |||
| 290 | + | return downloadutil.ExtractFilenameFromDestination(destination) | |||
| 291 | + | } | |||
| 292 | + | if item == nil { | |||
| 293 | + | return downloadutil.DefaultFilename | |||
| 294 | + | } | |||
| 295 | + | if suggested := item.GetSuggestedFileName(); suggested != "" { | |||
| 296 | + | return downloadutil.SanitizeFilenameWithExtension(suggested, item.GetMimeType()) | |||
| 297 | + | } | |||
| 298 | + | if uri := item.GetURL(); uri != "" { | |||
| 299 | + | return downloadutil.ExtractFilenameFromURI(uri) | |||
| 300 | + | } | |||
| 301 | + | return downloadutil.DefaultFilename | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func (a *cefDownloadResponseAdapter) GetMimeType() string { | |||
| 305 | + | if a == nil || a.item == nil { | |||
| 306 | + | return "" | |||
| 307 | + | } | |||
| 308 | + | return a.item.GetMimeType() | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | func (a *cefDownloadResponseAdapter) GetSuggestedFilename() string { | |||
| 312 | + | if a == nil || a.item == nil { | |||
| 313 | + | return "" | |||
| 314 | + | } | |||
| 315 | + | return a.item.GetSuggestedFileName() | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func (a *cefDownloadResponseAdapter) GetUri() string { | |||
| 319 | + | if a == nil || a.item == nil { | |||
| 320 | + | return "" | |||
| 321 | + | } | |||
| 322 | + | return a.item.GetURL() | |||
| 323 | + | } | |||
A internal/infrastructure/cef/download_handler_test.go +209 -0
| @@ -0,0 +1,209 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |
| 13 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | type stubDownloadItem struct { | |
| 17 | + | id uint32 | |
| 18 | + | url string | |
| 19 | + | suggested string | |
| 20 | + | mimeType string | |
| 21 | + | fullPath string | |
| 22 | + | complete bool | |
| 23 | + | canceled bool | |
| 24 | + | interrupted bool | |
| 25 | + | reason purecef.DownloadInterruptReason | |
| 26 | + | percentComplete int32 | |
| 27 | + | totalBytes int64 | |
| 28 | + | receivedBytes int64 | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (s stubDownloadItem) IsValid() bool { return true } | |
| 32 | + | func (s stubDownloadItem) IsInProgress() bool { return !s.complete && !s.canceled && !s.interrupted } | |
| 33 | + | func (s stubDownloadItem) IsComplete() bool { return s.complete } | |
| 34 | + | func (s stubDownloadItem) IsCanceled() bool { return s.canceled } | |
| 35 | + | func (s stubDownloadItem) IsInterrupted() bool { return s.interrupted } | |
| 36 | + | func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason { | |
| 37 | + | return s.reason | |
| 38 | + | } | |
| 39 | + | func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 } | |
| 40 | + | func (s stubDownloadItem) GetPercentComplete() int32 { return s.percentComplete } | |
| 41 | + | func (s stubDownloadItem) GetTotalBytes() int64 { return s.totalBytes } | |
| 42 | + | func (s stubDownloadItem) GetReceivedBytes() int64 { return s.receivedBytes } | |
| 43 | + | func (s stubDownloadItem) GetStartTime() uintptr { return 0 } | |
| 44 | + | func (s stubDownloadItem) GetEndTime() uintptr { return 0 } | |
| 45 | + | func (s stubDownloadItem) GetFullPath() string { return s.fullPath } | |
| 46 | + | func (s stubDownloadItem) GetID() uint32 { return s.id } | |
| 47 | + | func (s stubDownloadItem) GetURL() string { return s.url } | |
| 48 | + | func (s stubDownloadItem) GetOriginalURL() string { return s.url } | |
| 49 | + | func (s stubDownloadItem) GetSuggestedFileName() string { return s.suggested } | |
| 50 | + | func (s stubDownloadItem) GetContentDisposition() string { return "" } | |
| 51 | + | func (s stubDownloadItem) GetMimeType() string { return s.mimeType } | |
| 52 | + | func (s stubDownloadItem) IsPaused() bool { return false } | |
| 53 | + | ||
| 54 | + | type stubBeforeDownloadCallback struct { | |
| 55 | + | path string | |
| 56 | + | showDialog int32 | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (s *stubBeforeDownloadCallback) Cont(downloadPath string, showDialog int32) { | |
| 60 | + | s.path = downloadPath | |
| 61 | + | s.showDialog = showDialog | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | type captureDownloadEvents struct { | |
| 65 | + | events []port.DownloadEvent | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (c *captureDownloadEvents) OnDownloadEvent(_ context.Context, event port.DownloadEvent) { | |
| 69 | + | c.events = append(c.events, event) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestCEFDownloadHandlerLifecycle(t *testing.T) { | |
| 73 | + | ctx := context.Background() | |
| 74 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 75 | + | events := &captureDownloadEvents{} | |
| 76 | + | downloadDir := t.TempDir() | |
| 77 | + | handler := newDownloadHandler(downloadDir, events, preparer) | |
| 78 | + | callback := &stubBeforeDownloadCallback{} | |
| 79 | + | ||
| 80 | + | item := stubDownloadItem{ | |
| 81 | + | id: 7, | |
| 82 | + | url: "https://example.com/image.iso", | |
| 83 | + | suggested: "image.iso", | |
| 84 | + | mimeType: "application/x-iso9660-image", | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | ok := handler.onBeforeDownload(ctx, nil, item, item.suggested, callback) | |
| 88 | + | ||
| 89 | + | require.True(t, ok) | |
| 90 | + | require.Equal(t, filepath.Join(downloadDir, "image.iso"), callback.path) | |
| 91 | + | require.Len(t, events.events, 1) | |
| 92 | + | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | |
| 93 | + | ||
| 94 | + | item.complete = true | |
| 95 | + | item.fullPath = callback.path | |
| 96 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 97 | + | // Repeated completion updates should not emit duplicate finished events. | |
| 98 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 99 | + | ||
| 100 | + | require.Len(t, events.events, 2) | |
| 101 | + | require.Equal(t, port.DownloadEventFinished, events.events[1].Type) | |
| 102 | + | require.Equal(t, "image.iso", events.events[1].Filename) | |
| 103 | + | ||
| 104 | + | // Active map should be cleaned up after terminal event. | |
| 105 | + | handler.mu.Lock() | |
| 106 | + | _, inActive := handler.active[7] | |
| 107 | + | _, inFinished := handler.finished[7] | |
| 108 | + | handler.mu.Unlock() | |
| 109 | + | require.False(t, inActive, "completed download should be removed from active map") | |
| 110 | + | require.True(t, inFinished, "completed download should be in finished set") | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestCanDownload_AllowsAllMethods(t *testing.T) { | |
| 114 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 115 | + | handler := newDownloadHandler("/tmp/downloads", nil, preparer) | |
| 116 | + | ||
| 117 | + | require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "GET")) | |
| 118 | + | require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "POST")) | |
| 119 | + | require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "PUT")) | |
| 120 | + | require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "")) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) { | |
| 124 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 125 | + | handler := newDownloadHandler("/tmp/downloads", nil, preparer) | |
| 126 | + | ||
| 127 | + | // Simulate a download that was tracked through onBeforeDownload. | |
| 128 | + | handler.mu.Lock() | |
| 129 | + | handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1} | |
| 130 | + | handler.mu.Unlock() | |
| 131 | + | ||
| 132 | + | require.True(t, handler.markFinished(42), "first terminal event should succeed") | |
| 133 | + | ||
| 134 | + | // Simulate a concurrent update re-populating active before a duplicate terminal update. | |
| 135 | + | handler.mu.Lock() | |
| 136 | + | handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1} | |
| 137 | + | handler.mu.Unlock() | |
| 138 | + | ||
| 139 | + | require.False(t, handler.markFinished(42), "second terminal event should be suppressed") | |
| 140 | + | ||
| 141 | + | // Active map should not contain the entry. | |
| 142 | + | handler.mu.Lock() | |
| 143 | + | _, inActive := handler.active[42] | |
| 144 | + | handler.mu.Unlock() | |
| 145 | + | require.False(t, inActive) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) { | |
| 149 | + | ctx := context.Background() | |
| 150 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 151 | + | events := &captureDownloadEvents{} | |
| 152 | + | handler := newDownloadHandler("/tmp/downloads", events, preparer) | |
| 153 | + | callback := &stubBeforeDownloadCallback{} | |
| 154 | + | ||
| 155 | + | item := stubDownloadItem{ | |
| 156 | + | id: 11, | |
| 157 | + | url: "https://example.com/archive.zip", | |
| 158 | + | suggested: "archive.zip", | |
| 159 | + | mimeType: "application/zip", | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback)) | |
| 163 | + | ||
| 164 | + | item.fullPath = callback.path | |
| 165 | + | item.percentComplete = 12 | |
| 166 | + | item.receivedBytes = 12 | |
| 167 | + | item.totalBytes = 100 | |
| 168 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 169 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 170 | + | ||
| 171 | + | item.percentComplete = 13 | |
| 172 | + | item.receivedBytes = 13 | |
| 173 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 174 | + | ||
| 175 | + | require.Len(t, events.events, 3) | |
| 176 | + | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | |
| 177 | + | require.Equal(t, port.DownloadEventProgress, events.events[1].Type) | |
| 178 | + | require.InEpsilon(t, 0.12, events.events[1].Progress, 0.0001) | |
| 179 | + | require.Equal(t, port.DownloadEventProgress, events.events[2].Type) | |
| 180 | + | require.InEpsilon(t, 0.13, events.events[2].Progress, 0.0001) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) { | |
| 184 | + | ctx := context.Background() | |
| 185 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 186 | + | events := &captureDownloadEvents{} | |
| 187 | + | handler := newDownloadHandler("/tmp/downloads", events, preparer) | |
| 188 | + | callback := &stubBeforeDownloadCallback{} | |
| 189 | + | ||
| 190 | + | item := stubDownloadItem{ | |
| 191 | + | id: 9, | |
| 192 | + | url: "https://example.com/ubuntu.iso", | |
| 193 | + | suggested: "ubuntu.iso", | |
| 194 | + | mimeType: "application/x-iso9660-image", | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback)) | |
| 198 | + | ||
| 199 | + | item.fullPath = callback.path | |
| 200 | + | item.interrupted = true | |
| 201 | + | item.reason = purecef.DownloadInterruptReasonServerBadContent | |
| 202 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 203 | + | ||
| 204 | + | require.Len(t, events.events, 2) | |
| 205 | + | require.Error(t, events.events[1].Error) | |
| 206 | + | expectedCode := fmt.Sprintf("code=%d", purecef.DownloadInterruptReasonServerBadContent) | |
| 207 | + | require.ErrorContains(t, events.events[1].Error, expectedCode) | |
| 208 | + | require.ErrorContains(t, events.events[1].Error, "reason=server_bad_content") | |
| 209 | + | } | |
| @@ -0,0 +1,209 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 13 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type stubDownloadItem struct { | |||
| 17 | + | id uint32 | |||
| 18 | + | url string | |||
| 19 | + | suggested string | |||
| 20 | + | mimeType string | |||
| 21 | + | fullPath string | |||
| 22 | + | complete bool | |||
| 23 | + | canceled bool | |||
| 24 | + | interrupted bool | |||
| 25 | + | reason purecef.DownloadInterruptReason | |||
| 26 | + | percentComplete int32 | |||
| 27 | + | totalBytes int64 | |||
| 28 | + | receivedBytes int64 | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func (s stubDownloadItem) IsValid() bool { return true } | |||
| 32 | + | func (s stubDownloadItem) IsInProgress() bool { return !s.complete && !s.canceled && !s.interrupted } | |||
| 33 | + | func (s stubDownloadItem) IsComplete() bool { return s.complete } | |||
| 34 | + | func (s stubDownloadItem) IsCanceled() bool { return s.canceled } | |||
| 35 | + | func (s stubDownloadItem) IsInterrupted() bool { return s.interrupted } | |||
| 36 | + | func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason { | |||
| 37 | + | return s.reason | |||
| 38 | + | } | |||
| 39 | + | func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 } | |||
| 40 | + | func (s stubDownloadItem) GetPercentComplete() int32 { return s.percentComplete } | |||
| 41 | + | func (s stubDownloadItem) GetTotalBytes() int64 { return s.totalBytes } | |||
| 42 | + | func (s stubDownloadItem) GetReceivedBytes() int64 { return s.receivedBytes } | |||
| 43 | + | func (s stubDownloadItem) GetStartTime() uintptr { return 0 } | |||
| 44 | + | func (s stubDownloadItem) GetEndTime() uintptr { return 0 } | |||
| 45 | + | func (s stubDownloadItem) GetFullPath() string { return s.fullPath } | |||
| 46 | + | func (s stubDownloadItem) GetID() uint32 { return s.id } | |||
| 47 | + | func (s stubDownloadItem) GetURL() string { return s.url } | |||
| 48 | + | func (s stubDownloadItem) GetOriginalURL() string { return s.url } | |||
| 49 | + | func (s stubDownloadItem) GetSuggestedFileName() string { return s.suggested } | |||
| 50 | + | func (s stubDownloadItem) GetContentDisposition() string { return "" } | |||
| 51 | + | func (s stubDownloadItem) GetMimeType() string { return s.mimeType } | |||
| 52 | + | func (s stubDownloadItem) IsPaused() bool { return false } | |||
| 53 | + | ||||
| 54 | + | type stubBeforeDownloadCallback struct { | |||
| 55 | + | path string | |||
| 56 | + | showDialog int32 | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (s *stubBeforeDownloadCallback) Cont(downloadPath string, showDialog int32) { | |||
| 60 | + | s.path = downloadPath | |||
| 61 | + | s.showDialog = showDialog | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | type captureDownloadEvents struct { | |||
| 65 | + | events []port.DownloadEvent | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (c *captureDownloadEvents) OnDownloadEvent(_ context.Context, event port.DownloadEvent) { | |||
| 69 | + | c.events = append(c.events, event) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestCEFDownloadHandlerLifecycle(t *testing.T) { | |||
| 73 | + | ctx := context.Background() | |||
| 74 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |||
| 75 | + | events := &captureDownloadEvents{} | |||
| 76 | + | downloadDir := t.TempDir() | |||
| 77 | + | handler := newDownloadHandler(downloadDir, events, preparer) | |||
| 78 | + | callback := &stubBeforeDownloadCallback{} | |||
| 79 | + | ||||
| 80 | + | item := stubDownloadItem{ | |||
| 81 | + | id: 7, | |||
| 82 | + | url: "https://example.com/image.iso", | |||
| 83 | + | suggested: "image.iso", | |||
| 84 | + | mimeType: "application/x-iso9660-image", | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | ok := handler.onBeforeDownload(ctx, nil, item, item.suggested, callback) | |||
| 88 | + | ||||
| 89 | + | require.True(t, ok) | |||
| 90 | + | require.Equal(t, filepath.Join(downloadDir, "image.iso"), callback.path) | |||
| 91 | + | require.Len(t, events.events, 1) | |||
| 92 | + | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | |||
| 93 | + | ||||
| 94 | + | item.complete = true | |||
| 95 | + | item.fullPath = callback.path | |||
| 96 | + | handler.onDownloadUpdated(ctx, item, nil) | |||
| 97 | + | // Repeated completion updates should not emit duplicate finished events. | |||
| 98 | + | handler.onDownloadUpdated(ctx, item, nil) | |||
| 99 | + | ||||
| 100 | + | require.Len(t, events.events, 2) | |||
| 101 | + | require.Equal(t, port.DownloadEventFinished, events.events[1].Type) | |||
| 102 | + | require.Equal(t, "image.iso", events.events[1].Filename) | |||
| 103 | + | ||||
| 104 | + | // Active map should be cleaned up after terminal event. | |||
| 105 | + | handler.mu.Lock() | |||
| 106 | + | _, inActive := handler.active[7] | |||
| 107 | + | _, inFinished := handler.finished[7] | |||
| 108 | + | handler.mu.Unlock() | |||
| 109 | + | require.False(t, inActive, "completed download should be removed from active map") | |||
| 110 | + | require.True(t, inFinished, "completed download should be in finished set") | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestCanDownload_AllowsAllMethods(t *testing.T) { | |||
| 114 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |||
| 115 | + | handler := newDownloadHandler("/tmp/downloads", nil, preparer) | |||
| 116 | + | ||||
| 117 | + | require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "GET")) | |||
| 118 | + | require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "POST")) | |||
| 119 | + | require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "PUT")) | |||
| 120 | + | require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "")) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) { | |||
| 124 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |||
| 125 | + | handler := newDownloadHandler("/tmp/downloads", nil, preparer) | |||
| 126 | + | ||||
| 127 | + | // Simulate a download that was tracked through onBeforeDownload. | |||
| 128 | + | handler.mu.Lock() | |||
| 129 | + | handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1} | |||
| 130 | + | handler.mu.Unlock() | |||
| 131 | + | ||||
| 132 | + | require.True(t, handler.markFinished(42), "first terminal event should succeed") | |||
| 133 | + | ||||
| 134 | + | // Simulate a concurrent update re-populating active before a duplicate terminal update. | |||
| 135 | + | handler.mu.Lock() | |||
| 136 | + | handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1} | |||
| 137 | + | handler.mu.Unlock() | |||
| 138 | + | ||||
| 139 | + | require.False(t, handler.markFinished(42), "second terminal event should be suppressed") | |||
| 140 | + | ||||
| 141 | + | // Active map should not contain the entry. | |||
| 142 | + | handler.mu.Lock() | |||
| 143 | + | _, inActive := handler.active[42] | |||
| 144 | + | handler.mu.Unlock() | |||
| 145 | + | require.False(t, inActive) | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) { | |||
| 149 | + | ctx := context.Background() | |||
| 150 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |||
| 151 | + | events := &captureDownloadEvents{} | |||
| 152 | + | handler := newDownloadHandler("/tmp/downloads", events, preparer) | |||
| 153 | + | callback := &stubBeforeDownloadCallback{} | |||
| 154 | + | ||||
| 155 | + | item := stubDownloadItem{ | |||
| 156 | + | id: 11, | |||
| 157 | + | url: "https://example.com/archive.zip", | |||
| 158 | + | suggested: "archive.zip", | |||
| 159 | + | mimeType: "application/zip", | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback)) | |||
| 163 | + | ||||
| 164 | + | item.fullPath = callback.path | |||
| 165 | + | item.percentComplete = 12 | |||
| 166 | + | item.receivedBytes = 12 | |||
| 167 | + | item.totalBytes = 100 | |||
| 168 | + | handler.onDownloadUpdated(ctx, item, nil) | |||
| 169 | + | handler.onDownloadUpdated(ctx, item, nil) | |||
| 170 | + | ||||
| 171 | + | item.percentComplete = 13 | |||
| 172 | + | item.receivedBytes = 13 | |||
| 173 | + | handler.onDownloadUpdated(ctx, item, nil) | |||
| 174 | + | ||||
| 175 | + | require.Len(t, events.events, 3) | |||
| 176 | + | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | |||
| 177 | + | require.Equal(t, port.DownloadEventProgress, events.events[1].Type) | |||
| 178 | + | require.InEpsilon(t, 0.12, events.events[1].Progress, 0.0001) | |||
| 179 | + | require.Equal(t, port.DownloadEventProgress, events.events[2].Type) | |||
| 180 | + | require.InEpsilon(t, 0.13, events.events[2].Progress, 0.0001) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) { | |||
| 184 | + | ctx := context.Background() | |||
| 185 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |||
| 186 | + | events := &captureDownloadEvents{} | |||
| 187 | + | handler := newDownloadHandler("/tmp/downloads", events, preparer) | |||
| 188 | + | callback := &stubBeforeDownloadCallback{} | |||
| 189 | + | ||||
| 190 | + | item := stubDownloadItem{ | |||
| 191 | + | id: 9, | |||
| 192 | + | url: "https://example.com/ubuntu.iso", | |||
| 193 | + | suggested: "ubuntu.iso", | |||
| 194 | + | mimeType: "application/x-iso9660-image", | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback)) | |||
| 198 | + | ||||
| 199 | + | item.fullPath = callback.path | |||
| 200 | + | item.interrupted = true | |||
| 201 | + | item.reason = purecef.DownloadInterruptReasonServerBadContent | |||
| 202 | + | handler.onDownloadUpdated(ctx, item, nil) | |||
| 203 | + | ||||
| 204 | + | require.Len(t, events.events, 2) | |||
| 205 | + | require.Error(t, events.events[1].Error) | |||
| 206 | + | expectedCode := fmt.Sprintf("code=%d", purecef.DownloadInterruptReasonServerBadContent) | |||
| 207 | + | require.ErrorContains(t, events.events[1].Error, expectedCode) | |||
| 208 | + | require.ErrorContains(t, events.events[1].Error, "reason=server_bad_content") | |||
| 209 | + | } | |||
M internal/infrastructure/cef/engine.go +23 -5
| @@ -3,6 +3,7 @@ package cef | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/subtle" | |
| 6 | + | "fmt" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "sync/atomic" | |
| 8 | 9 | "time" | |
| @@ -42,6 +43,8 @@ type Engine struct { | |||
| 42 | 43 | onClipboardCopied func(textLen int) | |
| 43 | 44 | resolver port.ImageDataResolver | |
| 44 | 45 | mediaClassifier MediaClassifier | |
| 46 | + | downloadMu sync.RWMutex | |
| 47 | + | downloadHandler *downloadHandler | |
| 45 | 48 | alreadyRunningAppRelaunchMu sync.RWMutex | |
| 46 | 49 | alreadyRunningAppRelaunchHandler func(string) | |
| 47 | 50 | ||
| @@ -256,14 +259,20 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent | |||
| 256 | 259 | return e.registerAccentHandlers(ctx, e.messageRouter, handler) | |
| 257 | 260 | } | |
| 258 | 261 | ||
| 259 | - | // ConfigureDownloads sets up download handling (Phase 1 stub). | |
| 262 | + | // ConfigureDownloads sets up download handling for all CEF webviews. | |
| 260 | 263 | func (e *Engine) ConfigureDownloads( | |
| 261 | 264 | _ context.Context, | |
| 262 | - | _ string, | |
| 263 | - | _ port.DownloadEventHandler, | |
| 264 | - | _ port.DownloadPreparer, | |
| 265 | + | downloadPath string, | |
| 266 | + | eventHandler port.DownloadEventHandler, | |
| 267 | + | preparer port.DownloadPreparer, | |
| 265 | 268 | ) error { | |
| 266 | - | return ErrDownloadsUnsupported | |
| 269 | + | if preparer == nil { | |
| 270 | + | return fmt.Errorf("cef: download preparer is required") | |
| 271 | + | } | |
| 272 | + | e.downloadMu.Lock() | |
| 273 | + | e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer) | |
| 274 | + | e.downloadMu.Unlock() | |
| 275 | + | return nil | |
| 267 | 276 | } | |
| 268 | 277 | ||
| 269 | 278 | // OnToolkitReady is called after the UI toolkit has initialized. | |
| @@ -447,6 +456,15 @@ func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce stri | |||
| 447 | 456 | return valid | |
| 448 | 457 | } | |
| 449 | 458 | ||
| 459 | + | func (e *Engine) currentDownloadHandler() *downloadHandler { | |
| 460 | + | if e == nil { | |
| 461 | + | return nil | |
| 462 | + | } | |
| 463 | + | e.downloadMu.RLock() | |
| 464 | + | defer e.downloadMu.RUnlock() | |
| 465 | + | return e.downloadHandler | |
| 466 | + | } | |
| 467 | + | ||
| 450 | 468 | func (e *Engine) logTranscoderStartupState() { | |
| 451 | 469 | if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) { | |
| 452 | 470 | return | |
| @@ -3,6 +3,7 @@ package cef | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "crypto/subtle" | 5 | "crypto/subtle" | ||
| 6 | + | "fmt" | |||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "sync/atomic" | 8 | "sync/atomic" | ||
| 8 | "time" | 9 | "time" | ||
| @@ -42,6 +43,8 @@ type Engine struct { | |||||
| 42 | onClipboardCopied func(textLen int) | 43 | onClipboardCopied func(textLen int) | ||
| 43 | resolver port.ImageDataResolver | 44 | resolver port.ImageDataResolver | ||
| 44 | mediaClassifier MediaClassifier | 45 | mediaClassifier MediaClassifier | ||
| 46 | + | downloadMu sync.RWMutex | |||
| 47 | + | downloadHandler *downloadHandler | |||
| 45 | alreadyRunningAppRelaunchMu sync.RWMutex | 48 | alreadyRunningAppRelaunchMu sync.RWMutex | ||
| 46 | alreadyRunningAppRelaunchHandler func(string) | 49 | alreadyRunningAppRelaunchHandler func(string) | ||
| 47 | 50 | ||||
| @@ -256,14 +259,20 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent | |||||
| 256 | return e.registerAccentHandlers(ctx, e.messageRouter, handler) | 259 | return e.registerAccentHandlers(ctx, e.messageRouter, handler) | ||
| 257 | } | 260 | } | ||
| 258 | 261 | ||||
| 259 | - | // ConfigureDownloads sets up download handling (Phase 1 stub). | 262 | + | // ConfigureDownloads sets up download handling for all CEF webviews. |
| 260 | func (e *Engine) ConfigureDownloads( | 263 | func (e *Engine) ConfigureDownloads( | ||
| 261 | _ context.Context, | 264 | _ context.Context, | ||
| 262 | - | _ string, | 265 | + | downloadPath string, |
| 263 | - | _ port.DownloadEventHandler, | 266 | + | eventHandler port.DownloadEventHandler, |
| 264 | - | _ port.DownloadPreparer, | 267 | + | preparer port.DownloadPreparer, |
| 265 | ) error { | 268 | ) error { | ||
| 266 | - | return ErrDownloadsUnsupported | 269 | + | if preparer == nil { |
| 270 | + | return fmt.Errorf("cef: download preparer is required") | |||
| 271 | + | } | |||
| 272 | + | e.downloadMu.Lock() | |||
| 273 | + | e.downloadHandler = newDownloadHandler(downloadPath, eventHandler, preparer) | |||
| 274 | + | e.downloadMu.Unlock() | |||
| 275 | + | return nil | |||
| 267 | } | 276 | } | ||
| 268 | 277 | ||||
| 269 | // OnToolkitReady is called after the UI toolkit has initialized. | 278 | // OnToolkitReady is called after the UI toolkit has initialized. | ||
| @@ -447,6 +456,15 @@ func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce stri | |||||
| 447 | return valid | 456 | return valid | ||
| 448 | } | 457 | } | ||
| 449 | 458 | ||||
| 459 | + | func (e *Engine) currentDownloadHandler() *downloadHandler { | |||
| 460 | + | if e == nil { | |||
| 461 | + | return nil | |||
| 462 | + | } | |||
| 463 | + | e.downloadMu.RLock() | |||
| 464 | + | defer e.downloadMu.RUnlock() | |||
| 465 | + | return e.downloadHandler | |||
| 466 | + | } | |||
| 467 | + | ||||
| 450 | func (e *Engine) logTranscoderStartupState() { | 468 | func (e *Engine) logTranscoderStartupState() { | ||
| 451 | if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) { | 469 | if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) { | ||
| 452 | return | 470 | return | ||
M internal/infrastructure/cef/engine_contract_test.go +17 -2
| @@ -5,13 +5,28 @@ 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{} | |
| 14 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 15 | + | ||
| 16 | + | err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, preparer) | |
| 17 | + | ||
| 18 | + | require.NoError(t, err) | |
| 19 | + | require.NotNil(t, eng.currentDownloadHandler()) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestEngineConfigureDownloads_NilPreparer(t *testing.T) { | |
| 23 | + | eng := &Engine{} | |
| 24 | + | ||
| 13 | 25 | err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, nil) | |
| 14 | - | require.ErrorIs(t, err, ErrDownloadsUnsupported) | |
| 26 | + | ||
| 27 | + | require.Error(t, err) | |
| 28 | + | require.Contains(t, err.Error(), "preparer is required") | |
| 29 | + | require.Nil(t, eng.currentDownloadHandler()) | |
| 15 | 30 | } | |
| 16 | 31 | ||
| 17 | 32 | func TestWebViewFactoryCreateRelated_ReturnsUnsupported(t *testing.T) { | |
| @@ -5,13 +5,28 @@ import ( | |||||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/application/port" | 7 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 8 | "github.com/stretchr/testify/require" | 9 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | - | func TestEngineConfigureDownloads_ReturnsUnsupported(t *testing.T) { | 12 | + | func TestEngineConfigureDownloads_StoresHandler(t *testing.T) { |
| 12 | eng := &Engine{} | 13 | eng := &Engine{} | ||
| 14 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |||
| 15 | + | ||||
| 16 | + | err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, preparer) | |||
| 17 | + | ||||
| 18 | + | require.NoError(t, err) | |||
| 19 | + | require.NotNil(t, eng.currentDownloadHandler()) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestEngineConfigureDownloads_NilPreparer(t *testing.T) { | |||
| 23 | + | eng := &Engine{} | |||
| 24 | + | ||||
| 13 | err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, nil) | 25 | err := eng.ConfigureDownloads(context.Background(), "/tmp", nil, nil) | ||
| 14 | - | require.ErrorIs(t, err, ErrDownloadsUnsupported) | 26 | + | |
| 27 | + | require.Error(t, err) | |||
| 28 | + | require.Contains(t, err.Error(), "preparer is required") | |||
| 29 | + | require.Nil(t, eng.currentDownloadHandler()) | |||
| 15 | } | 30 | } | ||
| 16 | 31 | ||||
| 17 | func TestWebViewFactoryCreateRelated_ReturnsUnsupported(t *testing.T) { | 32 | func TestWebViewFactoryCreateRelated_ReturnsUnsupported(t *testing.T) { | ||
M internal/infrastructure/cef/webview_handlers.go +93 -15
| @@ -7,6 +7,7 @@ import ( | |||
| 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, 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, requestMethod string) bool { | |||
| 818 | + | handler := h.downloadHandler() | |||
| 819 | + | if handler == nil { | |||
| 820 | + | return false | |||
| 821 | + | } | |||
| 822 | + | return handler.canDownload(browser, url, requestMethod) | |||
| 823 | + | } | |||
| 824 | + | ||||
| 825 | + | func (h *handlerSet) OnBeforeDownload( | |||
| 826 | + | browser purecef.Browser, | |||
| 827 | + | downloadItem purecef.DownloadItem, | |||
| 828 | + | suggestedName string, | |||
| 829 | + | callback purecef.BeforeDownloadCallback, | |||
| 830 | + | ) bool { | |||
| 831 | + | handler := h.downloadHandler() | |||
| 832 | + | if handler == nil { | |||
| 833 | + | return false | |||
| 834 | + | } | |||
| 835 | + | return handler.onBeforeDownload(h.currentContext(), browser, downloadItem, suggestedName, callback) | |||
| 836 | + | } | |||
| 837 | + | ||||
| 838 | + | func (h *handlerSet) OnDownloadUpdated( | |||
| 839 | + | _ purecef.Browser, | |||
| 840 | + | downloadItem purecef.DownloadItem, | |||
| 841 | + | callback purecef.DownloadItemCallback, | |||
| 842 | + | ) { | |||
| 843 | + | handler := h.downloadHandler() | |||
| 844 | + | if handler == nil { | |||
| 845 | + | return | |||
| 846 | + | } | |||
| 847 | + | handler.onDownloadUpdated(h.currentContext(), downloadItem, callback) | |||
| 848 | + | } | |||
| 849 | + | ||||
| 850 | + | func (h *handlerSet) downloadHandler() *downloadHandler { | |||
| 851 | + | if h == nil || h.wv == nil || h.wv.engine == nil { | |||
| 852 | + | return nil | |||
| 853 | + | } | |||
| 854 | + | return h.wv.engine.currentDownloadHandler() | |||
| 855 | + | } | |||
| 856 | + | ||||
| 857 | + | func (h *handlerSet) currentContext() context.Context { | |||
| 858 | + | if h == nil || h.wv == nil || h.wv.ctx == nil { | |||
| 859 | + | return context.Background() | |||
| 860 | + | } | |||
| 861 | + | return h.wv.ctx | |||
| 784 | } | 862 | } | ||
| 785 | 863 | ||||
| 786 | func (h *handlerSet) OnOpenUrlfromTab( | 864 | func (h *handlerSet) OnOpenUrlfromTab( | ||
M internal/infrastructure/cef/webview_handlers_test.go +20 -0
| @@ -23,6 +23,12 @@ type clipboardOrchestratorRecorder struct { | |||
| 23 | 23 | selectionCalls int | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | type stubDownloadPreparer struct{} | |
| 27 | + | ||
| 28 | + | func (stubDownloadPreparer) Execute(context.Context, port.DownloadPrepareInput) *port.DownloadPrepareOutput { | |
| 29 | + | return &port.DownloadPrepareOutput{} | |
| 30 | + | } | |
| 31 | + | ||
| 26 | 32 | type controlledSelectionTimer struct { | |
| 27 | 33 | stopped bool | |
| 28 | 34 | } | |
| @@ -374,6 +380,20 @@ func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) { | |||
| 374 | 380 | require.Same(t, h, h.GetContextMenuHandler()) | |
| 375 | 381 | } | |
| 376 | 382 | ||
| 383 | + | func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) { | |
| 384 | + | eng := &Engine{} | |
| 385 | + | require.NoError(t, eng.ConfigureDownloads(context.Background(), "/tmp/downloads", nil, stubDownloadPreparer{})) | |
| 386 | + | ||
| 387 | + | h := &handlerSet{ | |
| 388 | + | wv: &WebView{ | |
| 389 | + | ctx: context.Background(), | |
| 390 | + | engine: eng, | |
| 391 | + | }, | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | require.Same(t, h, h.GetDownloadHandler()) | |
| 395 | + | } | |
| 396 | + | ||
| 377 | 397 | func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) { | |
| 378 | 398 | wv := &WebView{ | |
| 379 | 399 | id: 42, | |
| @@ -23,6 +23,12 @@ type clipboardOrchestratorRecorder struct { | |||||
| 23 | selectionCalls int | 23 | selectionCalls int | ||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | + | type stubDownloadPreparer struct{} | |||
| 27 | + | ||||
| 28 | + | func (stubDownloadPreparer) Execute(context.Context, port.DownloadPrepareInput) *port.DownloadPrepareOutput { | |||
| 29 | + | return &port.DownloadPrepareOutput{} | |||
| 30 | + | } | |||
| 31 | + | ||||
| 26 | type controlledSelectionTimer struct { | 32 | type controlledSelectionTimer struct { | ||
| 27 | stopped bool | 33 | stopped bool | ||
| 28 | } | 34 | } | ||
| @@ -374,6 +380,20 @@ func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) { | |||||
| 374 | require.Same(t, h, h.GetContextMenuHandler()) | 380 | require.Same(t, h, h.GetContextMenuHandler()) | ||
| 375 | } | 381 | } | ||
| 376 | 382 | ||||
| 383 | + | func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) { | |||
| 384 | + | eng := &Engine{} | |||
| 385 | + | require.NoError(t, eng.ConfigureDownloads(context.Background(), "/tmp/downloads", nil, stubDownloadPreparer{})) | |||
| 386 | + | ||||
| 387 | + | h := &handlerSet{ | |||
| 388 | + | wv: &WebView{ | |||
| 389 | + | ctx: context.Background(), | |||
| 390 | + | engine: eng, | |||
| 391 | + | }, | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | require.Same(t, h, h.GetDownloadHandler()) | |||
| 395 | + | } | |||
| 396 | + | ||||
| 377 | func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) { | 397 | func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) { | ||
| 378 | wv := &WebView{ | 398 | wv := &WebView{ | ||
| 379 | id: 42, | 399 | id: 42, | ||
A internal/infrastructure/downloadruntime/runtime.go +164 -0
| @@ -0,0 +1,164 @@ | |||
| 1 | + | package downloadruntime | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "sync" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | + | "github.com/bnema/dumber/internal/logging" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const dirPerm = 0o755 | |
| 15 | + | ||
| 16 | + | type codedDownloadError interface { | |
| 17 | + | DownloadErrorCode() int | |
| 18 | + | DownloadErrorReason() string | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // Runtime shares common download preparation and event emission logic across engines. | |
| 22 | + | type Runtime struct { | |
| 23 | + | mu sync.RWMutex | |
| 24 | + | downloadPath string | |
| 25 | + | eventHandler port.DownloadEventHandler | |
| 26 | + | preparer port.DownloadPreparer | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // New creates a Runtime. The caller must ensure preparer is non-nil; | |
| 30 | + | // ConfigureDownloads validates this before reaching here. | |
| 31 | + | func New(downloadPath string, eventHandler port.DownloadEventHandler, preparer port.DownloadPreparer) *Runtime { | |
| 32 | + | return &Runtime{ | |
| 33 | + | downloadPath: downloadPath, | |
| 34 | + | eventHandler: eventHandler, | |
| 35 | + | preparer: preparer, | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (r *Runtime) SetDownloadPath(path string) { | |
| 40 | + | r.mu.Lock() | |
| 41 | + | defer r.mu.Unlock() | |
| 42 | + | r.downloadPath = path | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (r *Runtime) ResolveDestination( | |
| 46 | + | ctx context.Context, | |
| 47 | + | suggestedFilename string, | |
| 48 | + | response port.DownloadResponse, | |
| 49 | + | ) (*port.DownloadPrepareOutput, error) { | |
| 50 | + | r.mu.RLock() | |
| 51 | + | downloadPath := r.downloadPath | |
| 52 | + | preparer := r.preparer | |
| 53 | + | r.mu.RUnlock() | |
| 54 | + | ||
| 55 | + | if preparer == nil { | |
| 56 | + | return nil, fmt.Errorf("download preparer is required") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | if err := os.MkdirAll(downloadPath, dirPerm); err != nil { | |
| 60 | + | return nil, fmt.Errorf("failed to create download directory %q: %w", downloadPath, err) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | output := preparer.Execute(ctx, port.DownloadPrepareInput{ | |
| 64 | + | SuggestedFilename: suggestedFilename, | |
| 65 | + | Response: response, | |
| 66 | + | DownloadDir: downloadPath, | |
| 67 | + | }) | |
| 68 | + | if output == nil { | |
| 69 | + | return nil, fmt.Errorf("failed to prepare download destination for %q", suggestedFilename) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | return output, nil | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func (r *Runtime) EmitStarted(ctx context.Context, output *port.DownloadPrepareOutput) { | |
| 76 | + | if output == nil { | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | r.mu.RLock() | |
| 81 | + | eventHandler := r.eventHandler | |
| 82 | + | r.mu.RUnlock() | |
| 83 | + | ||
| 84 | + | if eventHandler != nil { | |
| 85 | + | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 86 | + | Type: port.DownloadEventStarted, | |
| 87 | + | Filename: output.Filename, | |
| 88 | + | Destination: output.DestinationPath, | |
| 89 | + | }) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | logging.FromContext(ctx).Info(). | |
| 93 | + | Str("filename", output.Filename). | |
| 94 | + | Str("destination", output.DestinationPath). | |
| 95 | + | Msg("download started") | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (r *Runtime) EmitProgress( | |
| 99 | + | ctx context.Context, | |
| 100 | + | filename, destination string, | |
| 101 | + | progress float64, | |
| 102 | + | bytesReceived, bytesTotal int64, | |
| 103 | + | ) { | |
| 104 | + | r.mu.RLock() | |
| 105 | + | eventHandler := r.eventHandler | |
| 106 | + | r.mu.RUnlock() | |
| 107 | + | ||
| 108 | + | if eventHandler != nil { | |
| 109 | + | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 110 | + | Type: port.DownloadEventProgress, | |
| 111 | + | Filename: filename, | |
| 112 | + | Destination: destination, | |
| 113 | + | Progress: progress, | |
| 114 | + | BytesReceived: bytesReceived, | |
| 115 | + | BytesTotal: bytesTotal, | |
| 116 | + | }) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (r *Runtime) EmitFinished(ctx context.Context, filename, destination string) { | |
| 121 | + | r.mu.RLock() | |
| 122 | + | eventHandler := r.eventHandler | |
| 123 | + | r.mu.RUnlock() | |
| 124 | + | ||
| 125 | + | if eventHandler != nil { | |
| 126 | + | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 127 | + | Type: port.DownloadEventFinished, | |
| 128 | + | Filename: filename, | |
| 129 | + | Destination: destination, | |
| 130 | + | }) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | logging.FromContext(ctx).Info(). | |
| 134 | + | Str("filename", filename). | |
| 135 | + | Str("destination", destination). | |
| 136 | + | Msg("download finished") | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func (r *Runtime) EmitFailed(ctx context.Context, filename, destination string, err error) { | |
| 140 | + | r.mu.RLock() | |
| 141 | + | eventHandler := r.eventHandler | |
| 142 | + | r.mu.RUnlock() | |
| 143 | + | ||
| 144 | + | if eventHandler != nil { | |
| 145 | + | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 146 | + | Type: port.DownloadEventFailed, | |
| 147 | + | Filename: filename, | |
| 148 | + | Destination: destination, | |
| 149 | + | Error: err, | |
| 150 | + | }) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | log := logging.FromContext(ctx).Warn(). | |
| 154 | + | Err(err). | |
| 155 | + | Str("filename", filename). | |
| 156 | + | Str("destination", destination) | |
| 157 | + | var codedErr codedDownloadError | |
| 158 | + | if errors.As(err, &codedErr) { | |
| 159 | + | log = log. | |
| 160 | + | Int("error_code", codedErr.DownloadErrorCode()). | |
| 161 | + | Str("error_reason", codedErr.DownloadErrorReason()) | |
| 162 | + | } | |
| 163 | + | log.Msg("download failed") | |
| 164 | + | } | |
| @@ -0,0 +1,164 @@ | |||||
| 1 | + | package downloadruntime | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "sync" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 11 | + | "github.com/bnema/dumber/internal/logging" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const dirPerm = 0o755 | |||
| 15 | + | ||||
| 16 | + | type codedDownloadError interface { | |||
| 17 | + | DownloadErrorCode() int | |||
| 18 | + | DownloadErrorReason() string | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // Runtime shares common download preparation and event emission logic across engines. | |||
| 22 | + | type Runtime struct { | |||
| 23 | + | mu sync.RWMutex | |||
| 24 | + | downloadPath string | |||
| 25 | + | eventHandler port.DownloadEventHandler | |||
| 26 | + | preparer port.DownloadPreparer | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // New creates a Runtime. The caller must ensure preparer is non-nil; | |||
| 30 | + | // ConfigureDownloads validates this before reaching here. | |||
| 31 | + | func New(downloadPath string, eventHandler port.DownloadEventHandler, preparer port.DownloadPreparer) *Runtime { | |||
| 32 | + | return &Runtime{ | |||
| 33 | + | downloadPath: downloadPath, | |||
| 34 | + | eventHandler: eventHandler, | |||
| 35 | + | preparer: preparer, | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (r *Runtime) SetDownloadPath(path string) { | |||
| 40 | + | r.mu.Lock() | |||
| 41 | + | defer r.mu.Unlock() | |||
| 42 | + | r.downloadPath = path | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (r *Runtime) ResolveDestination( | |||
| 46 | + | ctx context.Context, | |||
| 47 | + | suggestedFilename string, | |||
| 48 | + | response port.DownloadResponse, | |||
| 49 | + | ) (*port.DownloadPrepareOutput, error) { | |||
| 50 | + | r.mu.RLock() | |||
| 51 | + | downloadPath := r.downloadPath | |||
| 52 | + | preparer := r.preparer | |||
| 53 | + | r.mu.RUnlock() | |||
| 54 | + | ||||
| 55 | + | if preparer == nil { | |||
| 56 | + | return nil, fmt.Errorf("download preparer is required") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | if err := os.MkdirAll(downloadPath, dirPerm); err != nil { | |||
| 60 | + | return nil, fmt.Errorf("failed to create download directory %q: %w", downloadPath, err) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | output := preparer.Execute(ctx, port.DownloadPrepareInput{ | |||
| 64 | + | SuggestedFilename: suggestedFilename, | |||
| 65 | + | Response: response, | |||
| 66 | + | DownloadDir: downloadPath, | |||
| 67 | + | }) | |||
| 68 | + | if output == nil { | |||
| 69 | + | return nil, fmt.Errorf("failed to prepare download destination for %q", suggestedFilename) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | return output, nil | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func (r *Runtime) EmitStarted(ctx context.Context, output *port.DownloadPrepareOutput) { | |||
| 76 | + | if output == nil { | |||
| 77 | + | return | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | r.mu.RLock() | |||
| 81 | + | eventHandler := r.eventHandler | |||
| 82 | + | r.mu.RUnlock() | |||
| 83 | + | ||||
| 84 | + | if eventHandler != nil { | |||
| 85 | + | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |||
| 86 | + | Type: port.DownloadEventStarted, | |||
| 87 | + | Filename: output.Filename, | |||
| 88 | + | Destination: output.DestinationPath, | |||
| 89 | + | }) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | logging.FromContext(ctx).Info(). | |||
| 93 | + | Str("filename", output.Filename). | |||
| 94 | + | Str("destination", output.DestinationPath). | |||
| 95 | + | Msg("download started") | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func (r *Runtime) EmitProgress( | |||
| 99 | + | ctx context.Context, | |||
| 100 | + | filename, destination string, | |||
| 101 | + | progress float64, | |||
| 102 | + | bytesReceived, bytesTotal int64, | |||
| 103 | + | ) { | |||
| 104 | + | r.mu.RLock() | |||
| 105 | + | eventHandler := r.eventHandler | |||
| 106 | + | r.mu.RUnlock() | |||
| 107 | + | ||||
| 108 | + | if eventHandler != nil { | |||
| 109 | + | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |||
| 110 | + | Type: port.DownloadEventProgress, | |||
| 111 | + | Filename: filename, | |||
| 112 | + | Destination: destination, | |||
| 113 | + | Progress: progress, | |||
| 114 | + | BytesReceived: bytesReceived, | |||
| 115 | + | BytesTotal: bytesTotal, | |||
| 116 | + | }) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func (r *Runtime) EmitFinished(ctx context.Context, filename, destination string) { | |||
| 121 | + | r.mu.RLock() | |||
| 122 | + | eventHandler := r.eventHandler | |||
| 123 | + | r.mu.RUnlock() | |||
| 124 | + | ||||
| 125 | + | if eventHandler != nil { | |||
| 126 | + | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |||
| 127 | + | Type: port.DownloadEventFinished, | |||
| 128 | + | Filename: filename, | |||
| 129 | + | Destination: destination, | |||
| 130 | + | }) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | logging.FromContext(ctx).Info(). | |||
| 134 | + | Str("filename", filename). | |||
| 135 | + | Str("destination", destination). | |||
| 136 | + | Msg("download finished") | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func (r *Runtime) EmitFailed(ctx context.Context, filename, destination string, err error) { | |||
| 140 | + | r.mu.RLock() | |||
| 141 | + | eventHandler := r.eventHandler | |||
| 142 | + | r.mu.RUnlock() | |||
| 143 | + | ||||
| 144 | + | if eventHandler != nil { | |||
| 145 | + | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |||
| 146 | + | Type: port.DownloadEventFailed, | |||
| 147 | + | Filename: filename, | |||
| 148 | + | Destination: destination, | |||
| 149 | + | Error: err, | |||
| 150 | + | }) | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | log := logging.FromContext(ctx).Warn(). | |||
| 154 | + | Err(err). | |||
| 155 | + | Str("filename", filename). | |||
| 156 | + | Str("destination", destination) | |||
| 157 | + | var codedErr codedDownloadError | |||
| 158 | + | if errors.As(err, &codedErr) { | |||
| 159 | + | log = log. | |||
| 160 | + | Int("error_code", codedErr.DownloadErrorCode()). | |||
| 161 | + | Str("error_reason", codedErr.DownloadErrorReason()) | |||
| 162 | + | } | |||
| 163 | + | log.Msg("download failed") | |||
| 164 | + | } | |||
A internal/infrastructure/downloadruntime/runtime_test.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | package downloadruntime | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type stubResponse struct { | |
| 14 | + | mimeType string | |
| 15 | + | uri string | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func (s stubResponse) GetMimeType() string { return s.mimeType } | |
| 19 | + | func (stubResponse) GetSuggestedFilename() string { return "" } | |
| 20 | + | func (s stubResponse) GetUri() string { return s.uri } | |
| 21 | + | ||
| 22 | + | type captureEvents struct { | |
| 23 | + | events []port.DownloadEvent | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (c *captureEvents) OnDownloadEvent(_ context.Context, event port.DownloadEvent) { | |
| 27 | + | c.events = append(c.events, event) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestRuntimeResolveDestinationAndEvents(t *testing.T) { | |
| 31 | + | ctx := context.Background() | |
| 32 | + | events := &captureEvents{} | |
| 33 | + | runtime := New(t.TempDir(), events, usecase.NewPrepareDownloadUseCase(nil)) | |
| 34 | + | ||
| 35 | + | output, err := runtime.ResolveDestination(ctx, "artifact", stubResponse{mimeType: "application/pdf"}) | |
| 36 | + | ||
| 37 | + | require.NoError(t, err) | |
| 38 | + | require.Equal(t, "artifact.pdf", output.Filename) | |
| 39 | + | runtime.EmitStarted(ctx, output) | |
| 40 | + | runtime.EmitProgress(ctx, output.Filename, output.DestinationPath, 0.42, 42, 100) | |
| 41 | + | runtime.EmitFinished(ctx, output.Filename, output.DestinationPath) | |
| 42 | + | ||
| 43 | + | require.Len(t, events.events, 3) | |
| 44 | + | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | |
| 45 | + | require.Equal(t, port.DownloadEventProgress, events.events[1].Type) | |
| 46 | + | require.InEpsilon(t, 0.42, events.events[1].Progress, 0.0001) | |
| 47 | + | require.EqualValues(t, 42, events.events[1].BytesReceived) | |
| 48 | + | require.EqualValues(t, 100, events.events[1].BytesTotal) | |
| 49 | + | require.Equal(t, port.DownloadEventFinished, events.events[2].Type) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestNewRuntime_NilPreparer_NoPanic(t *testing.T) { | |
| 53 | + | require.NotPanics(t, func() { | |
| 54 | + | _ = New("/tmp/downloads", nil, nil) | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestRuntimeResolveDestination_NilPreparerReturnsError(t *testing.T) { | |
| 59 | + | runtime := New(t.TempDir(), nil, nil) | |
| 60 | + | ||
| 61 | + | output, err := runtime.ResolveDestination(context.Background(), "artifact", stubResponse{}) | |
| 62 | + | ||
| 63 | + | require.Nil(t, output) | |
| 64 | + | require.Error(t, err) | |
| 65 | + | require.ErrorContains(t, err, "download preparer is required") | |
| 66 | + | } | |
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | package downloadruntime | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type stubResponse struct { | |||
| 14 | + | mimeType string | |||
| 15 | + | uri string | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func (s stubResponse) GetMimeType() string { return s.mimeType } | |||
| 19 | + | func (stubResponse) GetSuggestedFilename() string { return "" } | |||
| 20 | + | func (s stubResponse) GetUri() string { return s.uri } | |||
| 21 | + | ||||
| 22 | + | type captureEvents struct { | |||
| 23 | + | events []port.DownloadEvent | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (c *captureEvents) OnDownloadEvent(_ context.Context, event port.DownloadEvent) { | |||
| 27 | + | c.events = append(c.events, event) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestRuntimeResolveDestinationAndEvents(t *testing.T) { | |||
| 31 | + | ctx := context.Background() | |||
| 32 | + | events := &captureEvents{} | |||
| 33 | + | runtime := New(t.TempDir(), events, usecase.NewPrepareDownloadUseCase(nil)) | |||
| 34 | + | ||||
| 35 | + | output, err := runtime.ResolveDestination(ctx, "artifact", stubResponse{mimeType: "application/pdf"}) | |||
| 36 | + | ||||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | require.Equal(t, "artifact.pdf", output.Filename) | |||
| 39 | + | runtime.EmitStarted(ctx, output) | |||
| 40 | + | runtime.EmitProgress(ctx, output.Filename, output.DestinationPath, 0.42, 42, 100) | |||
| 41 | + | runtime.EmitFinished(ctx, output.Filename, output.DestinationPath) | |||
| 42 | + | ||||
| 43 | + | require.Len(t, events.events, 3) | |||
| 44 | + | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | |||
| 45 | + | require.Equal(t, port.DownloadEventProgress, events.events[1].Type) | |||
| 46 | + | require.InEpsilon(t, 0.42, events.events[1].Progress, 0.0001) | |||
| 47 | + | require.EqualValues(t, 42, events.events[1].BytesReceived) | |||
| 48 | + | require.EqualValues(t, 100, events.events[1].BytesTotal) | |||
| 49 | + | require.Equal(t, port.DownloadEventFinished, events.events[2].Type) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestNewRuntime_NilPreparer_NoPanic(t *testing.T) { | |||
| 53 | + | require.NotPanics(t, func() { | |||
| 54 | + | _ = New("/tmp/downloads", nil, nil) | |||
| 55 | + | }) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestRuntimeResolveDestination_NilPreparerReturnsError(t *testing.T) { | |||
| 59 | + | runtime := New(t.TempDir(), nil, nil) | |||
| 60 | + | ||||
| 61 | + | output, err := runtime.ResolveDestination(context.Background(), "artifact", stubResponse{}) | |||
| 62 | + | ||||
| 63 | + | require.Nil(t, output) | |||
| 64 | + | require.Error(t, err) | |||
| 65 | + | require.ErrorContains(t, err, "download preparer is required") | |||
| 66 | + | } | |||
M internal/infrastructure/webkit/download_handler.go +113 -86
| @@ -3,89 +3,90 @@ package webkit | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "os" | |
| 7 | 6 | "sync" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 9 | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/downloadruntime" | |
| 11 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 12 | "github.com/bnema/puregotk/v4/glib" | |
| 13 | 13 | "github.com/bnema/puregotk/v4/webkit" | |
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | - | const ( | |
| 17 | - | // dirPerm is the permission mode for creating download directories. | |
| 18 | - | dirPerm = 0755 | |
| 19 | - | ) | |
| 20 | - | ||
| 21 | 16 | // DownloadHandler manages WebKit downloads and notifies the UI layer. | |
| 22 | 17 | type DownloadHandler struct { | |
| 23 | - | downloadPath string | |
| 24 | - | eventHandler port.DownloadEventHandler | |
| 25 | - | preparer port.DownloadPreparer | |
| 26 | - | mu sync.RWMutex | |
| 18 | + | runtime *downloadruntime.Runtime | |
| 19 | + | mu sync.RWMutex | |
| 27 | 20 | } | |
| 28 | 21 | ||
| 29 | 22 | // NewDownloadHandler creates a new download handler. | |
| 30 | - | // Panics if preparer is nil (fail fast on misconfiguration). | |
| 23 | + | // The caller (ConfigureDownloads) must validate that preparer is non-nil. | |
| 31 | 24 | func NewDownloadHandler( | |
| 32 | 25 | downloadPath string, | |
| 33 | 26 | handler port.DownloadEventHandler, | |
| 34 | 27 | preparer port.DownloadPreparer, | |
| 35 | 28 | ) *DownloadHandler { | |
| 36 | - | if preparer == nil { | |
| 37 | - | panic("preparer is required") | |
| 38 | - | } | |
| 39 | 29 | return &DownloadHandler{ | |
| 40 | - | downloadPath: downloadPath, | |
| 41 | - | eventHandler: handler, | |
| 42 | - | preparer: preparer, | |
| 30 | + | runtime: downloadruntime.New(downloadPath, handler, preparer), | |
| 43 | 31 | } | |
| 44 | 32 | } | |
| 45 | 33 | ||
| 46 | 34 | // downloadState tracks the state of a single download to prevent duplicate notifications. | |
| 47 | 35 | // This is allocated per-download to ensure proper isolation between concurrent downloads. | |
| 48 | 36 | type downloadState struct { | |
| 49 | - | failed bool | |
| 50 | - | mu sync.Mutex | |
| 37 | + | failed bool | |
| 38 | + | filename string | |
| 39 | + | destination string | |
| 40 | + | lastProgressPercent int | |
| 41 | + | mu sync.Mutex | |
| 51 | 42 | } | |
| 52 | 43 | ||
| 53 | 44 | // HandleDownload sets up signal handlers for a new download. | |
| 54 | 45 | func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) { | |
| 55 | - | log := logging.FromContext(ctx) | |
| 46 | + | if download == nil { | |
| 47 | + | return | |
| 48 | + | } | |
| 56 | 49 | ||
| 57 | 50 | h.mu.RLock() | |
| 58 | - | downloadPath := h.downloadPath | |
| 59 | - | eventHandler := h.eventHandler | |
| 60 | - | preparer := h.preparer | |
| 51 | + | runtime := h.runtime | |
| 61 | 52 | h.mu.RUnlock() | |
| 62 | 53 | ||
| 63 | 54 | // Track download state in a dedicated struct to ensure proper isolation. | |
| 64 | 55 | // Each download gets its own state to prevent interference between concurrent downloads. | |
| 65 | - | state := &downloadState{} | |
| 66 | - | ||
| 67 | - | // Handle decide-destination signal to set download path. | |
| 68 | - | //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously) | |
| 69 | - | decideDestCb := func(d webkit.Download, suggestedFilename string) bool { | |
| 70 | - | // Ensure download directory exists. | |
| 71 | - | if err := os.MkdirAll(downloadPath, dirPerm); err != nil { | |
| 72 | - | log.Error().Err(err).Str("path", downloadPath).Msg("failed to create download directory") | |
| 73 | - | d.Cancel() | |
| 74 | - | return false | |
| 75 | - | } | |
| 56 | + | state := &downloadState{lastProgressPercent: -1} | |
| 57 | + | ||
| 58 | + | decideDestCb := makeDecideDestinationCallback(ctx, runtime, state) | |
| 59 | + | download.ConnectDecideDestination(&decideDestCb) | |
| 60 | + | ||
| 61 | + | progressCb := makeProgressCallback(ctx, runtime, state) | |
| 62 | + | download.ConnectReceivedData(&progressCb) | |
| 63 | + | ||
| 64 | + | failedCb := makeFailedCallback(ctx, runtime, state) | |
| 65 | + | download.ConnectFailed(&failedCb) | |
| 66 | + | ||
| 67 | + | finishedCb := makeFinishedCallback(ctx, runtime, state) | |
| 68 | + | download.ConnectFinished(&finishedCb) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func makeDecideDestinationCallback( | |
| 72 | + | ctx context.Context, | |
| 73 | + | runtime *downloadruntime.Runtime, | |
| 74 | + | state *downloadState, | |
| 75 | + | ) func(webkit.Download, string) bool { | |
| 76 | + | log := logging.FromContext(ctx) | |
| 76 | 77 | ||
| 77 | - | // Wrap WebKit response as port.DownloadResponse | |
| 78 | + | return func(d webkit.Download, suggestedFilename string) bool { | |
| 78 | 79 | var response port.DownloadResponse | |
| 79 | 80 | if resp := d.GetResponse(); resp != nil { | |
| 80 | 81 | response = &uriResponseAdapter{resp: resp} | |
| 81 | 82 | } | |
| 82 | 83 | ||
| 83 | - | // Use DownloadPreparer to resolve filename | |
| 84 | - | output := preparer.Execute(ctx, port.DownloadPrepareInput{ | |
| 85 | - | SuggestedFilename: suggestedFilename, | |
| 86 | - | Response: response, | |
| 87 | - | DownloadDir: downloadPath, | |
| 88 | - | }) | |
| 84 | + | output, err := runtime.ResolveDestination(ctx, suggestedFilename, response) | |
| 85 | + | if err != nil { | |
| 86 | + | log.Error().Err(err).Msg("failed to prepare download destination") | |
| 87 | + | d.Cancel() | |
| 88 | + | return false | |
| 89 | + | } | |
| 89 | 90 | ||
| 90 | 91 | log.Debug(). | |
| 91 | 92 | Str("suggested", suggestedFilename). | |
| @@ -93,84 +94,110 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D | |||
| 93 | 94 | Str("destPath", output.DestinationPath). | |
| 94 | 95 | Msg("setting download destination") | |
| 95 | 96 | ||
| 96 | - | // WebKit expects an absolute file path for the destination. | |
| 97 | 97 | d.SetDestination(output.DestinationPath) | |
| 98 | 98 | ||
| 99 | - | // Notify: download started. | |
| 100 | - | if eventHandler != nil { | |
| 101 | - | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 102 | - | Type: port.DownloadEventStarted, | |
| 103 | - | Filename: output.Filename, | |
| 104 | - | Destination: output.DestinationPath, | |
| 105 | - | }) | |
| 106 | - | } | |
| 99 | + | state.mu.Lock() | |
| 100 | + | state.filename = output.Filename | |
| 101 | + | state.destination = output.DestinationPath | |
| 102 | + | state.lastProgressPercent = -1 | |
| 103 | + | state.mu.Unlock() | |
| 107 | 104 | ||
| 108 | - | log.Info(). | |
| 109 | - | Str("filename", output.Filename). | |
| 110 | - | Str("destination", output.DestinationPath). | |
| 111 | - | Msg("download started") | |
| 105 | + | runtime.EmitStarted(ctx, output) | |
| 106 | + | emitWebKitDownloadProgress(ctx, runtime, state, d) | |
| 107 | + | return false | |
| 108 | + | } | |
| 109 | + | } | |
| 112 | 110 | ||
| 113 | - | return false // false = we handled it synchronously | |
| 111 | + | func makeProgressCallback( | |
| 112 | + | ctx context.Context, | |
| 113 | + | runtime *downloadruntime.Runtime, | |
| 114 | + | state *downloadState, | |
| 115 | + | ) func(webkit.Download, uint64) { | |
| 116 | + | return func(d webkit.Download, _ uint64) { | |
| 117 | + | emitWebKitDownloadProgress(ctx, runtime, state, d) | |
| 114 | 118 | } | |
| 115 | - | download.ConnectDecideDestination(&decideDestCb) | |
| 119 | + | } | |
| 116 | 120 | ||
| 117 | - | // Handle failed signal. | |
| 118 | - | failedCb := func(d webkit.Download, gerr *glib.Error) { | |
| 121 | + | func makeFailedCallback( | |
| 122 | + | ctx context.Context, | |
| 123 | + | runtime *downloadruntime.Runtime, | |
| 124 | + | state *downloadState, | |
| 125 | + | ) func(webkit.Download, *glib.Error) { | |
| 126 | + | return func(d webkit.Download, gerr *glib.Error) { | |
| 119 | 127 | state.mu.Lock() | |
| 120 | 128 | state.failed = true | |
| 121 | 129 | state.mu.Unlock() | |
| 122 | 130 | ||
| 123 | 131 | dest := d.GetDestination() | |
| 124 | 132 | filename := downloadutil.ExtractFilenameFromDestination(dest) | |
| 125 | - | ||
| 126 | - | // Create error for the failed download. | |
| 127 | 133 | downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo()) | |
| 128 | - | ||
| 129 | - | if eventHandler != nil { | |
| 130 | - | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 131 | - | Type: port.DownloadEventFailed, | |
| 132 | - | Filename: filename, | |
| 133 | - | Destination: dest, | |
| 134 | - | Error: downloadErr, | |
| 135 | - | }) | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | log.Warn().Str("filename", filename).Msg("download failed") | |
| 134 | + | runtime.EmitFailed(ctx, filename, dest, downloadErr) | |
| 139 | 135 | } | |
| 140 | - | download.ConnectFailed(&failedCb) | |
| 136 | + | } | |
| 141 | 137 | ||
| 142 | - | // Handle finished signal. | |
| 143 | - | finishedCb := func(d webkit.Download) { | |
| 138 | + | func makeFinishedCallback( | |
| 139 | + | ctx context.Context, | |
| 140 | + | runtime *downloadruntime.Runtime, | |
| 141 | + | state *downloadState, | |
| 142 | + | ) func(webkit.Download) { | |
| 143 | + | return func(d webkit.Download) { | |
| 144 | 144 | state.mu.Lock() | |
| 145 | 145 | failed := state.failed | |
| 146 | 146 | state.mu.Unlock() | |
| 147 | - | ||
| 148 | - | // Don't send finished event if we already sent a failed event. | |
| 149 | 147 | if failed { | |
| 150 | 148 | return | |
| 151 | 149 | } | |
| 152 | 150 | ||
| 153 | 151 | dest := d.GetDestination() | |
| 154 | 152 | filename := downloadutil.ExtractFilenameFromDestination(dest) | |
| 153 | + | runtime.EmitFinished(ctx, filename, dest) | |
| 154 | + | } | |
| 155 | + | } | |
| 155 | 156 | ||
| 156 | - | if eventHandler != nil { | |
| 157 | - | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 158 | - | Type: port.DownloadEventFinished, | |
| 159 | - | Filename: filename, | |
| 160 | - | Destination: dest, | |
| 161 | - | }) | |
| 162 | - | } | |
| 157 | + | func emitWebKitDownloadProgress( | |
| 158 | + | ctx context.Context, | |
| 159 | + | runtime *downloadruntime.Runtime, | |
| 160 | + | state *downloadState, | |
| 161 | + | d webkit.Download, | |
| 162 | + | ) { | |
| 163 | + | state.mu.Lock() | |
| 164 | + | if state.failed { | |
| 165 | + | state.mu.Unlock() | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | filename := state.filename | |
| 169 | + | destination := state.destination | |
| 170 | + | lastPercent := state.lastProgressPercent | |
| 171 | + | state.mu.Unlock() | |
| 172 | + | if filename == "" || destination == "" { | |
| 173 | + | return | |
| 174 | + | } | |
| 163 | 175 | ||
| 164 | - | log.Info().Str("filename", filename).Msg("download finished") | |
| 176 | + | progress := d.GetEstimatedProgress() | |
| 177 | + | if progress <= 0 || progress >= 1 { | |
| 178 | + | return | |
| 165 | 179 | } | |
| 166 | - | download.ConnectFinished(&finishedCb) | |
| 180 | + | percent := int(progress * 100) | |
| 181 | + | if percent <= lastPercent { | |
| 182 | + | return | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | state.mu.Lock() | |
| 186 | + | if state.failed || percent <= state.lastProgressPercent { | |
| 187 | + | state.mu.Unlock() | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | state.lastProgressPercent = percent | |
| 191 | + | state.mu.Unlock() | |
| 192 | + | ||
| 193 | + | runtime.EmitProgress(ctx, filename, destination, progress, int64(d.GetReceivedDataLength()), 0) | |
| 167 | 194 | } | |
| 168 | 195 | ||
| 169 | 196 | // SetDownloadPath updates the download directory. | |
| 170 | 197 | func (h *DownloadHandler) SetDownloadPath(path string) { | |
| 171 | 198 | h.mu.Lock() | |
| 172 | 199 | defer h.mu.Unlock() | |
| 173 | - | h.downloadPath = path | |
| 200 | + | h.runtime.SetDownloadPath(path) | |
| 174 | 201 | } | |
| 175 | 202 | ||
| 176 | 203 | // uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse. | |
| @@ -3,89 +3,90 @@ package webkit | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | - | "os" | |||
| 7 | "sync" | 6 | "sync" | ||
| 8 | 7 | ||||
| 9 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 10 | downloadutil "github.com/bnema/dumber/internal/domain/download" | 9 | downloadutil "github.com/bnema/dumber/internal/domain/download" | ||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/downloadruntime" | |||
| 11 | "github.com/bnema/dumber/internal/logging" | 11 | "github.com/bnema/dumber/internal/logging" | ||
| 12 | "github.com/bnema/puregotk/v4/glib" | 12 | "github.com/bnema/puregotk/v4/glib" | ||
| 13 | "github.com/bnema/puregotk/v4/webkit" | 13 | "github.com/bnema/puregotk/v4/webkit" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | - | const ( | |||
| 17 | - | // dirPerm is the permission mode for creating download directories. | |||
| 18 | - | dirPerm = 0755 | |||
| 19 | - | ) | |||
| 20 | - | ||||
| 21 | // DownloadHandler manages WebKit downloads and notifies the UI layer. | 16 | // DownloadHandler manages WebKit downloads and notifies the UI layer. | ||
| 22 | type DownloadHandler struct { | 17 | type DownloadHandler struct { | ||
| 23 | - | downloadPath string | 18 | + | runtime *downloadruntime.Runtime |
| 24 | - | eventHandler port.DownloadEventHandler | 19 | + | mu sync.RWMutex |
| 25 | - | preparer port.DownloadPreparer | |||
| 26 | - | mu sync.RWMutex | |||
| 27 | } | 20 | } | ||
| 28 | 21 | ||||
| 29 | // NewDownloadHandler creates a new download handler. | 22 | // NewDownloadHandler creates a new download handler. | ||
| 30 | - | // Panics if preparer is nil (fail fast on misconfiguration). | 23 | + | // The caller (ConfigureDownloads) must validate that preparer is non-nil. |
| 31 | func NewDownloadHandler( | 24 | func NewDownloadHandler( | ||
| 32 | downloadPath string, | 25 | downloadPath string, | ||
| 33 | handler port.DownloadEventHandler, | 26 | handler port.DownloadEventHandler, | ||
| 34 | preparer port.DownloadPreparer, | 27 | preparer port.DownloadPreparer, | ||
| 35 | ) *DownloadHandler { | 28 | ) *DownloadHandler { | ||
| 36 | - | if preparer == nil { | |||
| 37 | - | panic("preparer is required") | |||
| 38 | - | } | |||
| 39 | return &DownloadHandler{ | 29 | return &DownloadHandler{ | ||
| 40 | - | downloadPath: downloadPath, | 30 | + | runtime: downloadruntime.New(downloadPath, handler, preparer), |
| 41 | - | eventHandler: handler, | |||
| 42 | - | preparer: preparer, | |||
| 43 | } | 31 | } | ||
| 44 | } | 32 | } | ||
| 45 | 33 | ||||
| 46 | // downloadState tracks the state of a single download to prevent duplicate notifications. | 34 | // downloadState tracks the state of a single download to prevent duplicate notifications. | ||
| 47 | // This is allocated per-download to ensure proper isolation between concurrent downloads. | 35 | // This is allocated per-download to ensure proper isolation between concurrent downloads. | ||
| 48 | type downloadState struct { | 36 | type downloadState struct { | ||
| 49 | - | failed bool | 37 | + | failed bool |
| 50 | - | mu sync.Mutex | 38 | + | filename string |
| 39 | + | destination string | |||
| 40 | + | lastProgressPercent int | |||
| 41 | + | mu sync.Mutex | |||
| 51 | } | 42 | } | ||
| 52 | 43 | ||||
| 53 | // HandleDownload sets up signal handlers for a new download. | 44 | // HandleDownload sets up signal handlers for a new download. | ||
| 54 | func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) { | 45 | func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) { | ||
| 55 | - | log := logging.FromContext(ctx) | 46 | + | if download == nil { |
| 47 | + | return | |||
| 48 | + | } | |||
| 56 | 49 | ||||
| 57 | h.mu.RLock() | 50 | h.mu.RLock() | ||
| 58 | - | downloadPath := h.downloadPath | 51 | + | runtime := h.runtime |
| 59 | - | eventHandler := h.eventHandler | |||
| 60 | - | preparer := h.preparer | |||
| 61 | h.mu.RUnlock() | 52 | h.mu.RUnlock() | ||
| 62 | 53 | ||||
| 63 | // Track download state in a dedicated struct to ensure proper isolation. | 54 | // Track download state in a dedicated struct to ensure proper isolation. | ||
| 64 | // Each download gets its own state to prevent interference between concurrent downloads. | 55 | // Each download gets its own state to prevent interference between concurrent downloads. | ||
| 65 | - | state := &downloadState{} | 56 | + | state := &downloadState{lastProgressPercent: -1} |
| 66 | - | 57 | + | ||
| 67 | - | // Handle decide-destination signal to set download path. | 58 | + | decideDestCb := makeDecideDestinationCallback(ctx, runtime, state) |
| 68 | - | //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously) | 59 | + | download.ConnectDecideDestination(&decideDestCb) |
| 69 | - | decideDestCb := func(d webkit.Download, suggestedFilename string) bool { | 60 | + | |
| 70 | - | // Ensure download directory exists. | 61 | + | progressCb := makeProgressCallback(ctx, runtime, state) |
| 71 | - | if err := os.MkdirAll(downloadPath, dirPerm); err != nil { | 62 | + | download.ConnectReceivedData(&progressCb) |
| 72 | - | log.Error().Err(err).Str("path", downloadPath).Msg("failed to create download directory") | 63 | + | |
| 73 | - | d.Cancel() | 64 | + | failedCb := makeFailedCallback(ctx, runtime, state) |
| 74 | - | return false | 65 | + | download.ConnectFailed(&failedCb) |
| 75 | - | } | 66 | + | |
| 67 | + | finishedCb := makeFinishedCallback(ctx, runtime, state) | |||
| 68 | + | download.ConnectFinished(&finishedCb) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func makeDecideDestinationCallback( | |||
| 72 | + | ctx context.Context, | |||
| 73 | + | runtime *downloadruntime.Runtime, | |||
| 74 | + | state *downloadState, | |||
| 75 | + | ) func(webkit.Download, string) bool { | |||
| 76 | + | log := logging.FromContext(ctx) | |||
| 76 | 77 | ||||
| 77 | - | // Wrap WebKit response as port.DownloadResponse | 78 | + | return func(d webkit.Download, suggestedFilename string) bool { |
| 78 | var response port.DownloadResponse | 79 | var response port.DownloadResponse | ||
| 79 | if resp := d.GetResponse(); resp != nil { | 80 | if resp := d.GetResponse(); resp != nil { | ||
| 80 | response = &uriResponseAdapter{resp: resp} | 81 | response = &uriResponseAdapter{resp: resp} | ||
| 81 | } | 82 | } | ||
| 82 | 83 | ||||
| 83 | - | // Use DownloadPreparer to resolve filename | 84 | + | output, err := runtime.ResolveDestination(ctx, suggestedFilename, response) |
| 84 | - | output := preparer.Execute(ctx, port.DownloadPrepareInput{ | 85 | + | if err != nil { |
| 85 | - | SuggestedFilename: suggestedFilename, | 86 | + | log.Error().Err(err).Msg("failed to prepare download destination") |
| 86 | - | Response: response, | 87 | + | d.Cancel() |
| 87 | - | DownloadDir: downloadPath, | 88 | + | return false |
| 88 | - | }) | 89 | + | } |
| 89 | 90 | ||||
| 90 | log.Debug(). | 91 | log.Debug(). | ||
| 91 | Str("suggested", suggestedFilename). | 92 | Str("suggested", suggestedFilename). | ||
| @@ -93,84 +94,110 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D | |||||
| 93 | Str("destPath", output.DestinationPath). | 94 | Str("destPath", output.DestinationPath). | ||
| 94 | Msg("setting download destination") | 95 | Msg("setting download destination") | ||
| 95 | 96 | ||||
| 96 | - | // WebKit expects an absolute file path for the destination. | |||
| 97 | d.SetDestination(output.DestinationPath) | 97 | d.SetDestination(output.DestinationPath) | ||
| 98 | 98 | ||||
| 99 | - | // Notify: download started. | 99 | + | state.mu.Lock() |
| 100 | - | if eventHandler != nil { | 100 | + | state.filename = output.Filename |
| 101 | - | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | 101 | + | state.destination = output.DestinationPath |
| 102 | - | Type: port.DownloadEventStarted, | 102 | + | state.lastProgressPercent = -1 |
| 103 | - | Filename: output.Filename, | 103 | + | state.mu.Unlock() |
| 104 | - | Destination: output.DestinationPath, | |||
| 105 | - | }) | |||
| 106 | - | } | |||
| 107 | 104 | ||||
| 108 | - | log.Info(). | 105 | + | runtime.EmitStarted(ctx, output) |
| 109 | - | Str("filename", output.Filename). | 106 | + | emitWebKitDownloadProgress(ctx, runtime, state, d) |
| 110 | - | Str("destination", output.DestinationPath). | 107 | + | return false |
| 111 | - | Msg("download started") | 108 | + | } |
| 109 | + | } | |||
| 112 | 110 | ||||
| 113 | - | return false // false = we handled it synchronously | 111 | + | func makeProgressCallback( |
| 112 | + | ctx context.Context, | |||
| 113 | + | runtime *downloadruntime.Runtime, | |||
| 114 | + | state *downloadState, | |||
| 115 | + | ) func(webkit.Download, uint64) { | |||
| 116 | + | return func(d webkit.Download, _ uint64) { | |||
| 117 | + | emitWebKitDownloadProgress(ctx, runtime, state, d) | |||
| 114 | } | 118 | } | ||
| 115 | - | download.ConnectDecideDestination(&decideDestCb) | 119 | + | } |
| 116 | 120 | ||||
| 117 | - | // Handle failed signal. | 121 | + | func makeFailedCallback( |
| 118 | - | failedCb := func(d webkit.Download, gerr *glib.Error) { | 122 | + | ctx context.Context, |
| 123 | + | runtime *downloadruntime.Runtime, | |||
| 124 | + | state *downloadState, | |||
| 125 | + | ) func(webkit.Download, *glib.Error) { | |||
| 126 | + | return func(d webkit.Download, gerr *glib.Error) { | |||
| 119 | state.mu.Lock() | 127 | state.mu.Lock() | ||
| 120 | state.failed = true | 128 | state.failed = true | ||
| 121 | state.mu.Unlock() | 129 | state.mu.Unlock() | ||
| 122 | 130 | ||||
| 123 | dest := d.GetDestination() | 131 | dest := d.GetDestination() | ||
| 124 | filename := downloadutil.ExtractFilenameFromDestination(dest) | 132 | filename := downloadutil.ExtractFilenameFromDestination(dest) | ||
| 125 | - | ||||
| 126 | - | // Create error for the failed download. | |||
| 127 | downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo()) | 133 | downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo()) | ||
| 128 | - | 134 | + | runtime.EmitFailed(ctx, filename, dest, downloadErr) | |
| 129 | - | if eventHandler != nil { | |||
| 130 | - | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |||
| 131 | - | Type: port.DownloadEventFailed, | |||
| 132 | - | Filename: filename, | |||
| 133 | - | Destination: dest, | |||
| 134 | - | Error: downloadErr, | |||
| 135 | - | }) | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | - | log.Warn().Str("filename", filename).Msg("download failed") | |||
| 139 | } | 135 | } | ||
| 140 | - | download.ConnectFailed(&failedCb) | 136 | + | } |
| 141 | 137 | ||||
| 142 | - | // Handle finished signal. | 138 | + | func makeFinishedCallback( |
| 143 | - | finishedCb := func(d webkit.Download) { | 139 | + | ctx context.Context, |
| 140 | + | runtime *downloadruntime.Runtime, | |||
| 141 | + | state *downloadState, | |||
| 142 | + | ) func(webkit.Download) { | |||
| 143 | + | return func(d webkit.Download) { | |||
| 144 | state.mu.Lock() | 144 | state.mu.Lock() | ||
| 145 | failed := state.failed | 145 | failed := state.failed | ||
| 146 | state.mu.Unlock() | 146 | state.mu.Unlock() | ||
| 147 | - | ||||
| 148 | - | // Don't send finished event if we already sent a failed event. | |||
| 149 | if failed { | 147 | if failed { | ||
| 150 | return | 148 | return | ||
| 151 | } | 149 | } | ||
| 152 | 150 | ||||
| 153 | dest := d.GetDestination() | 151 | dest := d.GetDestination() | ||
| 154 | filename := downloadutil.ExtractFilenameFromDestination(dest) | 152 | filename := downloadutil.ExtractFilenameFromDestination(dest) | ||
| 153 | + | runtime.EmitFinished(ctx, filename, dest) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 155 | 156 | ||||
| 156 | - | if eventHandler != nil { | 157 | + | func emitWebKitDownloadProgress( |
| 157 | - | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | 158 | + | ctx context.Context, |
| 158 | - | Type: port.DownloadEventFinished, | 159 | + | runtime *downloadruntime.Runtime, |
| 159 | - | Filename: filename, | 160 | + | state *downloadState, |
| 160 | - | Destination: dest, | 161 | + | d webkit.Download, |
| 161 | - | }) | 162 | + | ) { |
| 162 | - | } | 163 | + | state.mu.Lock() |
| 164 | + | if state.failed { | |||
| 165 | + | state.mu.Unlock() | |||
| 166 | + | return | |||
| 167 | + | } | |||
| 168 | + | filename := state.filename | |||
| 169 | + | destination := state.destination | |||
| 170 | + | lastPercent := state.lastProgressPercent | |||
| 171 | + | state.mu.Unlock() | |||
| 172 | + | if filename == "" || destination == "" { | |||
| 173 | + | return | |||
| 174 | + | } | |||
| 163 | 175 | ||||
| 164 | - | log.Info().Str("filename", filename).Msg("download finished") | 176 | + | progress := d.GetEstimatedProgress() |
| 177 | + | if progress <= 0 || progress >= 1 { | |||
| 178 | + | return | |||
| 165 | } | 179 | } | ||
| 166 | - | download.ConnectFinished(&finishedCb) | 180 | + | percent := int(progress * 100) |
| 181 | + | if percent <= lastPercent { | |||
| 182 | + | return | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | state.mu.Lock() | |||
| 186 | + | if state.failed || percent <= state.lastProgressPercent { | |||
| 187 | + | state.mu.Unlock() | |||
| 188 | + | return | |||
| 189 | + | } | |||
| 190 | + | state.lastProgressPercent = percent | |||
| 191 | + | state.mu.Unlock() | |||
| 192 | + | ||||
| 193 | + | runtime.EmitProgress(ctx, filename, destination, progress, int64(d.GetReceivedDataLength()), 0) | |||
| 167 | } | 194 | } | ||
| 168 | 195 | ||||
| 169 | // SetDownloadPath updates the download directory. | 196 | // SetDownloadPath updates the download directory. | ||
| 170 | func (h *DownloadHandler) SetDownloadPath(path string) { | 197 | func (h *DownloadHandler) SetDownloadPath(path string) { | ||
| 171 | h.mu.Lock() | 198 | h.mu.Lock() | ||
| 172 | defer h.mu.Unlock() | 199 | defer h.mu.Unlock() | ||
| 173 | - | h.downloadPath = path | 200 | + | h.runtime.SetDownloadPath(path) |
| 174 | } | 201 | } | ||
| 175 | 202 | ||||
| 176 | // uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse. | 203 | // uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse. | ||
M internal/infrastructure/webkit/download_handler_test.go +11 -6
| @@ -1,10 +1,13 @@ | |||
| 1 | 1 | package webkit | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "path/filepath" | |
| 4 | 6 | "testing" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/bnema/dumber/internal/application/usecase" | |
| 7 | 9 | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 10 | 13 | func TestNewDownloadHandler(t *testing.T) { | |
| @@ -14,15 +17,13 @@ func TestNewDownloadHandler(t *testing.T) { | |||
| 14 | 17 | handler := NewDownloadHandler("/tmp/downloads", nil, prepareUC) | |
| 15 | 18 | ||
| 16 | 19 | assert.NotNil(t, handler) | |
| 17 | - | assert.Equal(t, "/tmp/downloads", handler.downloadPath) | |
| 18 | - | assert.Nil(t, handler.eventHandler) | |
| 19 | - | assert.NotNil(t, handler.preparer) | |
| 20 | + | require.NotNil(t, handler.runtime) | |
| 20 | 21 | }) | |
| 21 | 22 | ||
| 22 | 23 | t.Run("creates handler with custom path", func(t *testing.T) { | |
| 23 | 24 | handler := NewDownloadHandler("/custom/path", nil, prepareUC) | |
| 24 | 25 | ||
| 25 | - | assert.Equal(t, "/custom/path", handler.downloadPath) | |
| 26 | + | require.NotNil(t, handler.runtime) | |
| 26 | 27 | }) | |
| 27 | 28 | } | |
| 28 | 29 | ||
| @@ -30,9 +31,13 @@ func TestDownloadHandler_SetDownloadPath(t *testing.T) { | |||
| 30 | 31 | prepareUC := usecase.NewPrepareDownloadUseCase(nil) | |
| 31 | 32 | handler := NewDownloadHandler("/initial/path", nil, prepareUC) | |
| 32 | 33 | ||
| 33 | - | handler.SetDownloadPath("/new/path") | |
| 34 | + | newPath := t.TempDir() | |
| 35 | + | handler.SetDownloadPath(newPath) | |
| 34 | 36 | ||
| 35 | - | assert.Equal(t, "/new/path", handler.downloadPath) | |
| 37 | + | output, err := handler.runtime.ResolveDestination(context.Background(), "artifact.pdf", nil) | |
| 38 | + | ||
| 39 | + | require.NoError(t, err) | |
| 40 | + | assert.Equal(t, filepath.Join(newPath, "artifact.pdf"), output.DestinationPath) | |
| 36 | 41 | } | |
| 37 | 42 | ||
| 38 | 43 | func TestURIResponseAdapter(t *testing.T) { | |
| @@ -1,10 +1,13 @@ | |||||
| 1 | package webkit | 1 | package webkit | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "path/filepath" | |||
| 4 | "testing" | 6 | "testing" | ||
| 5 | 7 | ||||
| 6 | "github.com/bnema/dumber/internal/application/usecase" | 8 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 7 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 8 | ) | 11 | ) | ||
| 9 | 12 | ||||
| 10 | func TestNewDownloadHandler(t *testing.T) { | 13 | func TestNewDownloadHandler(t *testing.T) { | ||
| @@ -14,15 +17,13 @@ func TestNewDownloadHandler(t *testing.T) { | |||||
| 14 | handler := NewDownloadHandler("/tmp/downloads", nil, prepareUC) | 17 | handler := NewDownloadHandler("/tmp/downloads", nil, prepareUC) | ||
| 15 | 18 | ||||
| 16 | assert.NotNil(t, handler) | 19 | assert.NotNil(t, handler) | ||
| 17 | - | assert.Equal(t, "/tmp/downloads", handler.downloadPath) | 20 | + | require.NotNil(t, handler.runtime) |
| 18 | - | assert.Nil(t, handler.eventHandler) | |||
| 19 | - | assert.NotNil(t, handler.preparer) | |||
| 20 | }) | 21 | }) | ||
| 21 | 22 | ||||
| 22 | t.Run("creates handler with custom path", func(t *testing.T) { | 23 | t.Run("creates handler with custom path", func(t *testing.T) { | ||
| 23 | handler := NewDownloadHandler("/custom/path", nil, prepareUC) | 24 | handler := NewDownloadHandler("/custom/path", nil, prepareUC) | ||
| 24 | 25 | ||||
| 25 | - | assert.Equal(t, "/custom/path", handler.downloadPath) | 26 | + | require.NotNil(t, handler.runtime) |
| 26 | }) | 27 | }) | ||
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
| @@ -30,9 +31,13 @@ func TestDownloadHandler_SetDownloadPath(t *testing.T) { | |||||
| 30 | prepareUC := usecase.NewPrepareDownloadUseCase(nil) | 31 | prepareUC := usecase.NewPrepareDownloadUseCase(nil) | ||
| 31 | handler := NewDownloadHandler("/initial/path", nil, prepareUC) | 32 | handler := NewDownloadHandler("/initial/path", nil, prepareUC) | ||
| 32 | 33 | ||||
| 33 | - | handler.SetDownloadPath("/new/path") | 34 | + | newPath := t.TempDir() |
| 35 | + | handler.SetDownloadPath(newPath) | |||
| 34 | 36 | ||||
| 35 | - | assert.Equal(t, "/new/path", handler.downloadPath) | 37 | + | output, err := handler.runtime.ResolveDestination(context.Background(), "artifact.pdf", nil) |
| 38 | + | ||||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | assert.Equal(t, filepath.Join(newPath, "artifact.pdf"), output.DestinationPath) | |||
| 36 | } | 41 | } | ||
| 37 | 42 | ||||
| 38 | func TestURIResponseAdapter(t *testing.T) { | 43 | func TestURIResponseAdapter(t *testing.T) { | ||
M internal/infrastructure/webkit/engine.go +3 -0
| @@ -120,6 +120,9 @@ func (e *Engine) ConfigureDownloads( | |||
| 120 | 120 | if e.wkCtx == nil { | |
| 121 | 121 | return fmt.Errorf("webkit context not initialized") | |
| 122 | 122 | } | |
| 123 | + | if preparer == nil { | |
| 124 | + | return fmt.Errorf("download preparer is required") | |
| 125 | + | } | |
| 123 | 126 | handler := NewDownloadHandler(downloadPath, eventHandler, preparer) | |
| 124 | 127 | e.wkCtx.SetDownloadHandler(ctx, handler) | |
| 125 | 128 | e.downloadPath = downloadPath | |
| @@ -120,6 +120,9 @@ func (e *Engine) ConfigureDownloads( | |||||
| 120 | if e.wkCtx == nil { | 120 | if e.wkCtx == nil { | ||
| 121 | return fmt.Errorf("webkit context not initialized") | 121 | return fmt.Errorf("webkit context not initialized") | ||
| 122 | } | 122 | } | ||
| 123 | + | if preparer == nil { | |||
| 124 | + | return fmt.Errorf("download preparer is required") | |||
| 125 | + | } | |||
| 123 | handler := NewDownloadHandler(downloadPath, eventHandler, preparer) | 126 | handler := NewDownloadHandler(downloadPath, eventHandler, preparer) | ||
| 124 | e.wkCtx.SetDownloadHandler(ctx, handler) | 127 | e.wkCtx.SetDownloadHandler(ctx, handler) | ||
| 125 | e.downloadPath = downloadPath | 128 | e.downloadPath = downloadPath | ||
M internal/infrastructure/webkit/engine_runtime_test.go +8 -0
| @@ -46,6 +46,14 @@ func TestEngine_ConfigureDownloads_NilContext(t *testing.T) { | |||
| 46 | 46 | require.Contains(t, err.Error(), "webkit context not initialized") | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | func TestNewDownloadHandler_NilPreparer_NoPanic(t *testing.T) { | |
| 50 | + | // After removing the panic, NewDownloadHandler with nil preparer should | |
| 51 | + | // not panic. The caller (ConfigureDownloads) is responsible for validation. | |
| 52 | + | require.NotPanics(t, func() { | |
| 53 | + | _ = NewDownloadHandler("/tmp", nil, nil) | |
| 54 | + | }) | |
| 55 | + | } | |
| 56 | + | ||
| 49 | 57 | func TestEngine_Close_NilPool(t *testing.T) { | |
| 50 | 58 | e := &Engine{} | |
| 51 | 59 | err := e.Close() | |
| @@ -46,6 +46,14 @@ func TestEngine_ConfigureDownloads_NilContext(t *testing.T) { | |||||
| 46 | require.Contains(t, err.Error(), "webkit context not initialized") | 46 | require.Contains(t, err.Error(), "webkit context not initialized") | ||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | + | func TestNewDownloadHandler_NilPreparer_NoPanic(t *testing.T) { | |||
| 50 | + | // After removing the panic, NewDownloadHandler with nil preparer should | |||
| 51 | + | // not panic. The caller (ConfigureDownloads) is responsible for validation. | |||
| 52 | + | require.NotPanics(t, func() { | |||
| 53 | + | _ = NewDownloadHandler("/tmp", nil, nil) | |||
| 54 | + | }) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 49 | func TestEngine_Close_NilPool(t *testing.T) { | 57 | func TestEngine_Close_NilPool(t *testing.T) { | ||
| 50 | e := &Engine{} | 58 | e := &Engine{} | ||
| 51 | err := e.Close() | 59 | err := e.Close() | ||
M internal/infrastructure/webkit/webview.go +2 -16
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "time" | |
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/dumber/internal/application/port" | |
| 14 | + | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| 14 | 15 | "github.com/bnema/dumber/internal/domain/entity" | |
| 15 | 16 | urlutil "github.com/bnema/dumber/internal/domain/url" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| @@ -824,22 +825,7 @@ func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool { | |||
| 824 | 825 | return false | |
| 825 | 826 | } | |
| 826 | 827 | ||
| 827 | - | mimeType := strings.ToLower(response.GetMimeType()) | |
| 828 | - | if strings.HasPrefix(mimeType, "application/pdf") { | |
| 829 | - | return true | |
| 830 | - | } | |
| 831 | - | ||
| 832 | - | uri := response.GetUri() | |
| 833 | - | if uri == "" { | |
| 834 | - | return false | |
| 835 | - | } | |
| 836 | - | ||
| 837 | - | parsed, err := url.Parse(uri) | |
| 838 | - | if err != nil { | |
| 839 | - | return strings.Contains(strings.ToLower(uri), ".pdf") | |
| 840 | - | } | |
| 841 | - | ||
| 842 | - | return strings.HasSuffix(strings.ToLower(parsed.Path), ".pdf") | |
| 828 | + | return downloadutil.ShouldForceDownload(response.GetUri(), response.GetMimeType()) | |
| 843 | 829 | } | |
| 844 | 830 | ||
| 845 | 831 | func (wv *WebView) connectEnterFullscreenSignal() { | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "time" | 11 | "time" | ||
| 12 | 12 | ||||
| 13 | "github.com/bnema/dumber/internal/application/port" | 13 | "github.com/bnema/dumber/internal/application/port" | ||
| 14 | + | downloadutil "github.com/bnema/dumber/internal/domain/download" | |||
| 14 | "github.com/bnema/dumber/internal/domain/entity" | 15 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 15 | urlutil "github.com/bnema/dumber/internal/domain/url" | 16 | urlutil "github.com/bnema/dumber/internal/domain/url" | ||
| 16 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 17 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| @@ -824,22 +825,7 @@ func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool { | |||||
| 824 | return false | 825 | return false | ||
| 825 | } | 826 | } | ||
| 826 | 827 | ||||
| 827 | - | mimeType := strings.ToLower(response.GetMimeType()) | 828 | + | return downloadutil.ShouldForceDownload(response.GetUri(), response.GetMimeType()) |
| 828 | - | if strings.HasPrefix(mimeType, "application/pdf") { | |||
| 829 | - | return true | |||
| 830 | - | } | |||
| 831 | - | ||||
| 832 | - | uri := response.GetUri() | |||
| 833 | - | if uri == "" { | |||
| 834 | - | return false | |||
| 835 | - | } | |||
| 836 | - | ||||
| 837 | - | parsed, err := url.Parse(uri) | |||
| 838 | - | if err != nil { | |||
| 839 | - | return strings.Contains(strings.ToLower(uri), ".pdf") | |||
| 840 | - | } | |||
| 841 | - | ||||
| 842 | - | return strings.HasSuffix(strings.ToLower(parsed.Path), ".pdf") | |||
| 843 | } | 829 | } | ||
| 844 | 830 | ||||
| 845 | func (wv *WebView) connectEnterFullscreenSignal() { | 831 | func (wv *WebView) connectEnterFullscreenSignal() { | ||
M internal/ui/app.go +134 -16
| @@ -40,12 +40,14 @@ const ( | |||
| 40 | 40 | AppID = "com.github.bnema.dumber" | |
| 41 | 41 | appTitle = "Dumber" | |
| 42 | 42 | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | |
| 43 | - | crashReportToastDurationMs = 5000 | |
| 44 | - | floatingPaneFallbackWidth = 1200 | |
| 45 | - | floatingPaneFallbackHeight = 800 | |
| 46 | - | floatingPaneIDPrefix = "floating-pane:" | |
| 47 | - | floatingSessionIDDefault = "default" | |
| 48 | - | floatingPaneVisibleClass = "floating-pane-visible" | |
| 43 | + | crashReportToastDurationMs = 5000 | |
| 44 | + | downloadToastTerminalDuration = 2500 | |
| 45 | + | downloadProgressRoundBias = 0.5 | |
| 46 | + | floatingPaneFallbackWidth = 1200 | |
| 47 | + | floatingPaneFallbackHeight = 800 | |
| 48 | + | floatingPaneIDPrefix = "floating-pane:" | |
| 49 | + | floatingSessionIDDefault = "default" | |
| 50 | + | floatingPaneVisibleClass = "floating-pane-visible" | |
| 49 | 51 | ) | |
| 50 | 52 | ||
| 51 | 53 | func gtkApplicationFlags() gio.ApplicationFlags { | |
| @@ -889,7 +891,7 @@ func (a *App) initDownloadHandler(ctx context.Context) { | |||
| 889 | 891 | } | |
| 890 | 892 | ||
| 891 | 893 | // Create download event adapter to show toasts. | |
| 892 | - | eventAdapter := &downloadEventAdapter{app: a} | |
| 894 | + | eventAdapter := newDownloadEventAdapter(a) | |
| 893 | 895 | ||
| 894 | 896 | // Create use case for preparing download destinations with file deduplication. | |
| 895 | 897 | prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem) | |
| @@ -904,25 +906,141 @@ func (a *App) initDownloadHandler(ctx context.Context) { | |||
| 904 | 906 | ||
| 905 | 907 | // downloadEventAdapter implements port.DownloadEventHandler and shows toasts. | |
| 906 | 908 | type downloadEventAdapter struct { | |
| 907 | - | app *App | |
| 909 | + | app *App | |
| 910 | + | mu sync.Mutex | |
| 911 | + | active map[string]downloadToastState | |
| 912 | + | } | |
| 913 | + | ||
| 914 | + | type downloadToastState struct { | |
| 915 | + | filename string | |
| 916 | + | percent int | |
| 917 | + | } | |
| 918 | + | ||
| 919 | + | type downloadToastSpec struct { | |
| 920 | + | message string | |
| 921 | + | level component.ToastLevel | |
| 922 | + | duration int | |
| 923 | + | show bool | |
| 924 | + | } | |
| 925 | + | ||
| 926 | + | func newDownloadEventAdapter(app *App) *downloadEventAdapter { | |
| 927 | + | return &downloadEventAdapter{ | |
| 928 | + | app: app, | |
| 929 | + | active: make(map[string]downloadToastState), | |
| 930 | + | } | |
| 908 | 931 | } | |
| 909 | 932 | ||
| 910 | 933 | func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) { | |
| 934 | + | spec := d.toastSpecForEvent(event) | |
| 935 | + | if !spec.show { | |
| 936 | + | return | |
| 937 | + | } | |
| 938 | + | ||
| 911 | 939 | // Must schedule on GTK main thread. | |
| 912 | 940 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 913 | - | switch event.Type { | |
| 914 | - | case port.DownloadEventStarted: | |
| 915 | - | d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download started: "+event.Filename, component.ToastInfo) | |
| 916 | - | case port.DownloadEventFinished: | |
| 917 | - | d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download complete: "+event.Filename, component.ToastSuccess) | |
| 918 | - | case port.DownloadEventFailed: | |
| 919 | - | d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download failed: "+event.Filename, component.ToastError) | |
| 920 | - | } | |
| 941 | + | d.app.showToastOnLastFocusedBrowserWindow( | |
| 942 | + | ctx, | |
| 943 | + | spec.message, | |
| 944 | + | spec.level, | |
| 945 | + | component.WithDuration(spec.duration), | |
| 946 | + | ) | |
| 921 | 947 | return false | |
| 922 | 948 | }) | |
| 923 | 949 | glib.IdleAdd(&cb, 0) | |
| 924 | 950 | } | |
| 925 | 951 | ||
| 952 | + | func (d *downloadEventAdapter) toastSpecForEvent(event port.DownloadEvent) downloadToastSpec { | |
| 953 | + | key := downloadToastKey(event) | |
| 954 | + | filename := event.Filename | |
| 955 | + | if filename == "" { | |
| 956 | + | filename = filepath.Base(event.Destination) | |
| 957 | + | } | |
| 958 | + | if filename == "" { | |
| 959 | + | filename = "download" | |
| 960 | + | } | |
| 961 | + | ||
| 962 | + | d.mu.Lock() | |
| 963 | + | defer d.mu.Unlock() | |
| 964 | + | ||
| 965 | + | state := d.active[key] | |
| 966 | + | if state.filename == "" { | |
| 967 | + | state.filename = filename | |
| 968 | + | } | |
| 969 | + | ||
| 970 | + | switch event.Type { | |
| 971 | + | case port.DownloadEventStarted: | |
| 972 | + | state.percent = 0 | |
| 973 | + | d.active[key] = state | |
| 974 | + | return downloadToastSpec{ | |
| 975 | + | message: fmt.Sprintf("Download started: %s (0%%)", state.filename), | |
| 976 | + | level: component.ToastInfo, | |
| 977 | + | duration: 0, | |
| 978 | + | show: true, | |
| 979 | + | } | |
| 980 | + | case port.DownloadEventProgress: | |
| 981 | + | percent := downloadProgressPercent(event.Progress) | |
| 982 | + | if percent <= state.percent { | |
| 983 | + | if _, ok := d.active[key]; !ok { | |
| 984 | + | d.active[key] = state | |
| 985 | + | } | |
| 986 | + | return downloadToastSpec{} | |
| 987 | + | } | |
| 988 | + | state.percent = percent | |
| 989 | + | d.active[key] = state | |
| 990 | + | return downloadToastSpec{ | |
| 991 | + | message: fmt.Sprintf("Downloading: %s (%d%%)", state.filename, percent), | |
| 992 | + | level: component.ToastInfo, | |
| 993 | + | duration: 0, | |
| 994 | + | show: true, | |
| 995 | + | } | |
| 996 | + | case port.DownloadEventFinished: | |
| 997 | + | delete(d.active, key) | |
| 998 | + | return downloadToastSpec{ | |
| 999 | + | message: "Download complete: " + state.filename, | |
| 1000 | + | level: component.ToastSuccess, | |
| 1001 | + | duration: downloadToastTerminalDuration, | |
| 1002 | + | show: true, | |
| 1003 | + | } | |
| 1004 | + | case port.DownloadEventFailed: | |
| 1005 | + | delete(d.active, key) | |
| 1006 | + | return downloadToastSpec{ | |
| 1007 | + | message: "Download failed: " + state.filename, | |
| 1008 | + | level: component.ToastError, | |
| 1009 | + | duration: downloadToastTerminalDuration, | |
| 1010 | + | show: true, | |
| 1011 | + | } | |
| 1012 | + | default: | |
| 1013 | + | return downloadToastSpec{} | |
| 1014 | + | } | |
| 1015 | + | } | |
| 1016 | + | ||
| 1017 | + | func downloadToastKey(event port.DownloadEvent) string { | |
| 1018 | + | if event.Destination != "" { | |
| 1019 | + | return event.Destination | |
| 1020 | + | } | |
| 1021 | + | if event.Filename != "" { | |
| 1022 | + | return event.Filename | |
| 1023 | + | } | |
| 1024 | + | return "download" | |
| 1025 | + | } | |
| 1026 | + | ||
| 1027 | + | func downloadProgressPercent(progress float64) int { | |
| 1028 | + | if progress <= 0 { | |
| 1029 | + | return 0 | |
| 1030 | + | } | |
| 1031 | + | if progress >= 1 { | |
| 1032 | + | return 100 | |
| 1033 | + | } | |
| 1034 | + | percent := int(progress*100 + downloadProgressRoundBias) | |
| 1035 | + | if percent < 0 { | |
| 1036 | + | return 0 | |
| 1037 | + | } | |
| 1038 | + | if percent > 100 { | |
| 1039 | + | return 100 | |
| 1040 | + | } | |
| 1041 | + | return percent | |
| 1042 | + | } | |
| 1043 | + | ||
| 926 | 1044 | // autoCopyConfigFn implements port.AutoCopyConfig using a function closure. | |
| 927 | 1045 | type autoCopyConfigFn struct { | |
| 928 | 1046 | fn func() bool | |
| @@ -40,12 +40,14 @@ const ( | |||||
| 40 | AppID = "com.github.bnema.dumber" | 40 | AppID = "com.github.bnema.dumber" | ||
| 41 | appTitle = "Dumber" | 41 | appTitle = "Dumber" | ||
| 42 | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | 42 | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | ||
| 43 | - | crashReportToastDurationMs = 5000 | 43 | + | crashReportToastDurationMs = 5000 |
| 44 | - | floatingPaneFallbackWidth = 1200 | 44 | + | downloadToastTerminalDuration = 2500 |
| 45 | - | floatingPaneFallbackHeight = 800 | 45 | + | downloadProgressRoundBias = 0.5 |
| 46 | - | floatingPaneIDPrefix = "floating-pane:" | 46 | + | floatingPaneFallbackWidth = 1200 |
| 47 | - | floatingSessionIDDefault = "default" | 47 | + | floatingPaneFallbackHeight = 800 |
| 48 | - | floatingPaneVisibleClass = "floating-pane-visible" | 48 | + | floatingPaneIDPrefix = "floating-pane:" |
| 49 | + | floatingSessionIDDefault = "default" | |||
| 50 | + | floatingPaneVisibleClass = "floating-pane-visible" | |||
| 49 | ) | 51 | ) | ||
| 50 | 52 | ||||
| 51 | func gtkApplicationFlags() gio.ApplicationFlags { | 53 | func gtkApplicationFlags() gio.ApplicationFlags { | ||
| @@ -889,7 +891,7 @@ func (a *App) initDownloadHandler(ctx context.Context) { | |||||
| 889 | } | 891 | } | ||
| 890 | 892 | ||||
| 891 | // Create download event adapter to show toasts. | 893 | // Create download event adapter to show toasts. | ||
| 892 | - | eventAdapter := &downloadEventAdapter{app: a} | 894 | + | eventAdapter := newDownloadEventAdapter(a) |
| 893 | 895 | ||||
| 894 | // Create use case for preparing download destinations with file deduplication. | 896 | // Create use case for preparing download destinations with file deduplication. | ||
| 895 | prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem) | 897 | prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem) | ||
| @@ -904,25 +906,141 @@ func (a *App) initDownloadHandler(ctx context.Context) { | |||||
| 904 | 906 | ||||
| 905 | // downloadEventAdapter implements port.DownloadEventHandler and shows toasts. | 907 | // downloadEventAdapter implements port.DownloadEventHandler and shows toasts. | ||
| 906 | type downloadEventAdapter struct { | 908 | type downloadEventAdapter struct { | ||
| 907 | - | app *App | 909 | + | app *App |
| 910 | + | mu sync.Mutex | |||
| 911 | + | active map[string]downloadToastState | |||
| 912 | + | } | |||
| 913 | + | ||||
| 914 | + | type downloadToastState struct { | |||
| 915 | + | filename string | |||
| 916 | + | percent int | |||
| 917 | + | } | |||
| 918 | + | ||||
| 919 | + | type downloadToastSpec struct { | |||
| 920 | + | message string | |||
| 921 | + | level component.ToastLevel | |||
| 922 | + | duration int | |||
| 923 | + | show bool | |||
| 924 | + | } | |||
| 925 | + | ||||
| 926 | + | func newDownloadEventAdapter(app *App) *downloadEventAdapter { | |||
| 927 | + | return &downloadEventAdapter{ | |||
| 928 | + | app: app, | |||
| 929 | + | active: make(map[string]downloadToastState), | |||
| 930 | + | } | |||
| 908 | } | 931 | } | ||
| 909 | 932 | ||||
| 910 | func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) { | 933 | func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) { | ||
| 934 | + | spec := d.toastSpecForEvent(event) | |||
| 935 | + | if !spec.show { | |||
| 936 | + | return | |||
| 937 | + | } | |||
| 938 | + | ||||
| 911 | // Must schedule on GTK main thread. | 939 | // Must schedule on GTK main thread. | ||
| 912 | cb := glib.SourceFunc(func(_ uintptr) bool { | 940 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 913 | - | switch event.Type { | 941 | + | d.app.showToastOnLastFocusedBrowserWindow( |
| 914 | - | case port.DownloadEventStarted: | 942 | + | ctx, |
| 915 | - | d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download started: "+event.Filename, component.ToastInfo) | 943 | + | spec.message, |
| 916 | - | case port.DownloadEventFinished: | 944 | + | spec.level, |
| 917 | - | d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download complete: "+event.Filename, component.ToastSuccess) | 945 | + | component.WithDuration(spec.duration), |
| 918 | - | case port.DownloadEventFailed: | 946 | + | ) |
| 919 | - | d.app.showToastOnLastFocusedBrowserWindow(ctx, "Download failed: "+event.Filename, component.ToastError) | |||
| 920 | - | } | |||
| 921 | return false | 947 | return false | ||
| 922 | }) | 948 | }) | ||
| 923 | glib.IdleAdd(&cb, 0) | 949 | glib.IdleAdd(&cb, 0) | ||
| 924 | } | 950 | } | ||
| 925 | 951 | ||||
| 952 | + | func (d *downloadEventAdapter) toastSpecForEvent(event port.DownloadEvent) downloadToastSpec { | |||
| 953 | + | key := downloadToastKey(event) | |||
| 954 | + | filename := event.Filename | |||
| 955 | + | if filename == "" { | |||
| 956 | + | filename = filepath.Base(event.Destination) | |||
| 957 | + | } | |||
| 958 | + | if filename == "" { | |||
| 959 | + | filename = "download" | |||
| 960 | + | } | |||
| 961 | + | ||||
| 962 | + | d.mu.Lock() | |||
| 963 | + | defer d.mu.Unlock() | |||
| 964 | + | ||||
| 965 | + | state := d.active[key] | |||
| 966 | + | if state.filename == "" { | |||
| 967 | + | state.filename = filename | |||
| 968 | + | } | |||
| 969 | + | ||||
| 970 | + | switch event.Type { | |||
| 971 | + | case port.DownloadEventStarted: | |||
| 972 | + | state.percent = 0 | |||
| 973 | + | d.active[key] = state | |||
| 974 | + | return downloadToastSpec{ | |||
| 975 | + | message: fmt.Sprintf("Download started: %s (0%%)", state.filename), | |||
| 976 | + | level: component.ToastInfo, | |||
| 977 | + | duration: 0, | |||
| 978 | + | show: true, | |||
| 979 | + | } | |||
| 980 | + | case port.DownloadEventProgress: | |||
| 981 | + | percent := downloadProgressPercent(event.Progress) | |||
| 982 | + | if percent <= state.percent { | |||
| 983 | + | if _, ok := d.active[key]; !ok { | |||
| 984 | + | d.active[key] = state | |||
| 985 | + | } | |||
| 986 | + | return downloadToastSpec{} | |||
| 987 | + | } | |||
| 988 | + | state.percent = percent | |||
| 989 | + | d.active[key] = state | |||
| 990 | + | return downloadToastSpec{ | |||
| 991 | + | message: fmt.Sprintf("Downloading: %s (%d%%)", state.filename, percent), | |||
| 992 | + | level: component.ToastInfo, | |||
| 993 | + | duration: 0, | |||
| 994 | + | show: true, | |||
| 995 | + | } | |||
| 996 | + | case port.DownloadEventFinished: | |||
| 997 | + | delete(d.active, key) | |||
| 998 | + | return downloadToastSpec{ | |||
| 999 | + | message: "Download complete: " + state.filename, | |||
| 1000 | + | level: component.ToastSuccess, | |||
| 1001 | + | duration: downloadToastTerminalDuration, | |||
| 1002 | + | show: true, | |||
| 1003 | + | } | |||
| 1004 | + | case port.DownloadEventFailed: | |||
| 1005 | + | delete(d.active, key) | |||
| 1006 | + | return downloadToastSpec{ | |||
| 1007 | + | message: "Download failed: " + state.filename, | |||
| 1008 | + | level: component.ToastError, | |||
| 1009 | + | duration: downloadToastTerminalDuration, | |||
| 1010 | + | show: true, | |||
| 1011 | + | } | |||
| 1012 | + | default: | |||
| 1013 | + | return downloadToastSpec{} | |||
| 1014 | + | } | |||
| 1015 | + | } | |||
| 1016 | + | ||||
| 1017 | + | func downloadToastKey(event port.DownloadEvent) string { | |||
| 1018 | + | if event.Destination != "" { | |||
| 1019 | + | return event.Destination | |||
| 1020 | + | } | |||
| 1021 | + | if event.Filename != "" { | |||
| 1022 | + | return event.Filename | |||
| 1023 | + | } | |||
| 1024 | + | return "download" | |||
| 1025 | + | } | |||
| 1026 | + | ||||
| 1027 | + | func downloadProgressPercent(progress float64) int { | |||
| 1028 | + | if progress <= 0 { | |||
| 1029 | + | return 0 | |||
| 1030 | + | } | |||
| 1031 | + | if progress >= 1 { | |||
| 1032 | + | return 100 | |||
| 1033 | + | } | |||
| 1034 | + | percent := int(progress*100 + downloadProgressRoundBias) | |||
| 1035 | + | if percent < 0 { | |||
| 1036 | + | return 0 | |||
| 1037 | + | } | |||
| 1038 | + | if percent > 100 { | |||
| 1039 | + | return 100 | |||
| 1040 | + | } | |||
| 1041 | + | return percent | |||
| 1042 | + | } | |||
| 1043 | + | ||||
| 926 | // autoCopyConfigFn implements port.AutoCopyConfig using a function closure. | 1044 | // autoCopyConfigFn implements port.AutoCopyConfig using a function closure. | ||
| 927 | type autoCopyConfigFn struct { | 1045 | type autoCopyConfigFn struct { | ||
| 928 | fn func() bool | 1046 | fn func() bool | ||
A internal/ui/app_download_test.go +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestDownloadEventAdapterToastSpecLifecycle(t *testing.T) { | |
| 13 | + | adapter := newDownloadEventAdapter(nil) | |
| 14 | + | event := port.DownloadEvent{ | |
| 15 | + | Filename: "archlinux.iso", | |
| 16 | + | Destination: "/tmp/archlinux.iso", | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | started := adapter.toastSpecForEvent(port.DownloadEvent{ | |
| 20 | + | Type: port.DownloadEventStarted, | |
| 21 | + | Filename: event.Filename, | |
| 22 | + | Destination: event.Destination, | |
| 23 | + | }) | |
| 24 | + | require.True(t, started.show) | |
| 25 | + | require.Equal(t, component.ToastInfo, started.level) | |
| 26 | + | require.Equal(t, 0, started.duration) | |
| 27 | + | require.Equal(t, "Download started: archlinux.iso (0%)", started.message) | |
| 28 | + | ||
| 29 | + | progress := adapter.toastSpecForEvent(port.DownloadEvent{ | |
| 30 | + | Type: port.DownloadEventProgress, | |
| 31 | + | Filename: event.Filename, | |
| 32 | + | Destination: event.Destination, | |
| 33 | + | Progress: 0.37, | |
| 34 | + | }) | |
| 35 | + | require.True(t, progress.show) | |
| 36 | + | require.Equal(t, component.ToastInfo, progress.level) | |
| 37 | + | require.Equal(t, 0, progress.duration) | |
| 38 | + | require.Equal(t, "Downloading: archlinux.iso (37%)", progress.message) | |
| 39 | + | ||
| 40 | + | finished := adapter.toastSpecForEvent(port.DownloadEvent{ | |
| 41 | + | Type: port.DownloadEventFinished, | |
| 42 | + | Filename: event.Filename, | |
| 43 | + | Destination: event.Destination, | |
| 44 | + | }) | |
| 45 | + | require.True(t, finished.show) | |
| 46 | + | require.Equal(t, component.ToastSuccess, finished.level) | |
| 47 | + | require.Equal(t, downloadToastTerminalDuration, finished.duration) | |
| 48 | + | require.Equal(t, "Download complete: archlinux.iso", finished.message) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestDownloadEventAdapterToastSpecSuppressesDuplicateProgress(t *testing.T) { | |
| 52 | + | adapter := newDownloadEventAdapter(nil) | |
| 53 | + | event := port.DownloadEvent{ | |
| 54 | + | Filename: "ubuntu.iso", | |
| 55 | + | Destination: "/tmp/ubuntu.iso", | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | adapter.toastSpecForEvent(port.DownloadEvent{ | |
| 59 | + | Type: port.DownloadEventStarted, | |
| 60 | + | Filename: event.Filename, | |
| 61 | + | Destination: event.Destination, | |
| 62 | + | }) | |
| 63 | + | ||
| 64 | + | first := adapter.toastSpecForEvent(port.DownloadEvent{ | |
| 65 | + | Type: port.DownloadEventProgress, | |
| 66 | + | Filename: event.Filename, | |
| 67 | + | Destination: event.Destination, | |
| 68 | + | Progress: 0.12, | |
| 69 | + | }) | |
| 70 | + | require.True(t, first.show) | |
| 71 | + | ||
| 72 | + | duplicate := adapter.toastSpecForEvent(port.DownloadEvent{ | |
| 73 | + | Type: port.DownloadEventProgress, | |
| 74 | + | Filename: event.Filename, | |
| 75 | + | Destination: event.Destination, | |
| 76 | + | Progress: 0.12, | |
| 77 | + | }) | |
| 78 | + | require.False(t, duplicate.show) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestDownloadProgressPercentRoundsReasonably(t *testing.T) { | |
| 82 | + | require.Equal(t, 0, downloadProgressPercent(0)) | |
| 83 | + | require.Equal(t, 1, downloadProgressPercent(0.0051)) | |
| 84 | + | require.Equal(t, 37, downloadProgressPercent(0.37)) | |
| 85 | + | require.Equal(t, 100, downloadProgressPercent(1.2)) | |
| 86 | + | } | |
| @@ -0,0 +1,86 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/bnema/dumber/internal/ui/component" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestDownloadEventAdapterToastSpecLifecycle(t *testing.T) { | |||
| 13 | + | adapter := newDownloadEventAdapter(nil) | |||
| 14 | + | event := port.DownloadEvent{ | |||
| 15 | + | Filename: "archlinux.iso", | |||
| 16 | + | Destination: "/tmp/archlinux.iso", | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | started := adapter.toastSpecForEvent(port.DownloadEvent{ | |||
| 20 | + | Type: port.DownloadEventStarted, | |||
| 21 | + | Filename: event.Filename, | |||
| 22 | + | Destination: event.Destination, | |||
| 23 | + | }) | |||
| 24 | + | require.True(t, started.show) | |||
| 25 | + | require.Equal(t, component.ToastInfo, started.level) | |||
| 26 | + | require.Equal(t, 0, started.duration) | |||
| 27 | + | require.Equal(t, "Download started: archlinux.iso (0%)", started.message) | |||
| 28 | + | ||||
| 29 | + | progress := adapter.toastSpecForEvent(port.DownloadEvent{ | |||
| 30 | + | Type: port.DownloadEventProgress, | |||
| 31 | + | Filename: event.Filename, | |||
| 32 | + | Destination: event.Destination, | |||
| 33 | + | Progress: 0.37, | |||
| 34 | + | }) | |||
| 35 | + | require.True(t, progress.show) | |||
| 36 | + | require.Equal(t, component.ToastInfo, progress.level) | |||
| 37 | + | require.Equal(t, 0, progress.duration) | |||
| 38 | + | require.Equal(t, "Downloading: archlinux.iso (37%)", progress.message) | |||
| 39 | + | ||||
| 40 | + | finished := adapter.toastSpecForEvent(port.DownloadEvent{ | |||
| 41 | + | Type: port.DownloadEventFinished, | |||
| 42 | + | Filename: event.Filename, | |||
| 43 | + | Destination: event.Destination, | |||
| 44 | + | }) | |||
| 45 | + | require.True(t, finished.show) | |||
| 46 | + | require.Equal(t, component.ToastSuccess, finished.level) | |||
| 47 | + | require.Equal(t, downloadToastTerminalDuration, finished.duration) | |||
| 48 | + | require.Equal(t, "Download complete: archlinux.iso", finished.message) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestDownloadEventAdapterToastSpecSuppressesDuplicateProgress(t *testing.T) { | |||
| 52 | + | adapter := newDownloadEventAdapter(nil) | |||
| 53 | + | event := port.DownloadEvent{ | |||
| 54 | + | Filename: "ubuntu.iso", | |||
| 55 | + | Destination: "/tmp/ubuntu.iso", | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | adapter.toastSpecForEvent(port.DownloadEvent{ | |||
| 59 | + | Type: port.DownloadEventStarted, | |||
| 60 | + | Filename: event.Filename, | |||
| 61 | + | Destination: event.Destination, | |||
| 62 | + | }) | |||
| 63 | + | ||||
| 64 | + | first := adapter.toastSpecForEvent(port.DownloadEvent{ | |||
| 65 | + | Type: port.DownloadEventProgress, | |||
| 66 | + | Filename: event.Filename, | |||
| 67 | + | Destination: event.Destination, | |||
| 68 | + | Progress: 0.12, | |||
| 69 | + | }) | |||
| 70 | + | require.True(t, first.show) | |||
| 71 | + | ||||
| 72 | + | duplicate := adapter.toastSpecForEvent(port.DownloadEvent{ | |||
| 73 | + | Type: port.DownloadEventProgress, | |||
| 74 | + | Filename: event.Filename, | |||
| 75 | + | Destination: event.Destination, | |||
| 76 | + | Progress: 0.12, | |||
| 77 | + | }) | |||
| 78 | + | require.False(t, duplicate.show) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestDownloadProgressPercentRoundsReasonably(t *testing.T) { | |||
| 82 | + | require.Equal(t, 0, downloadProgressPercent(0)) | |||
| 83 | + | require.Equal(t, 1, downloadProgressPercent(0.0051)) | |||
| 84 | + | require.Equal(t, 37, downloadProgressPercent(0.37)) | |||
| 85 | + | require.Equal(t, 100, downloadProgressPercent(1.2)) | |||
| 86 | + | } | |||