feat(download): keep toast alive and show progress
- commit
- 401758e5122193bf4f81f5f248bdb8e420e24b6d
- author
- brice · · 2026-04-19 20:09 UTC
- parent
- db3c5c7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +470 -61
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. | ||
M internal/infrastructure/cef/download_handler.go +61 -4
| @@ -27,8 +27,9 @@ type downloadHandler struct { | |||
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | 29 | type cefDownloadState struct { | |
| 30 | - | filename string | |
| 31 | - | destination string | |
| 30 | + | filename string | |
| 31 | + | destination string | |
| 32 | + | lastProgressPercent int32 | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | type cefDownloadInterruptError struct { | |
| @@ -134,8 +135,9 @@ func (h *downloadHandler) onBeforeDownload( | |||
| 134 | 135 | ||
| 135 | 136 | h.mu.Lock() | |
| 136 | 137 | h.active[downloadItem.GetID()] = cefDownloadState{ | |
| 137 | - | filename: output.Filename, | |
| 138 | - | destination: output.DestinationPath, | |
| 138 | + | filename: output.Filename, | |
| 139 | + | destination: output.DestinationPath, | |
| 140 | + | lastProgressPercent: -1, | |
| 139 | 141 | } | |
| 140 | 142 | h.mu.Unlock() | |
| 141 | 143 | ||
| @@ -166,6 +168,7 @@ func (h *downloadHandler) onDownloadUpdated( | |||
| 166 | 168 | } | |
| 167 | 169 | ||
| 168 | 170 | state := h.currentState(id, downloadItem) | |
| 171 | + | h.emitProgressIfNeeded(ctx, id, downloadItem, state) | |
| 169 | 172 | ||
| 170 | 173 | switch { | |
| 171 | 174 | case downloadItem.IsComplete(): | |
| @@ -208,6 +211,60 @@ func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cef | |||
| 208 | 211 | return state | |
| 209 | 212 | } | |
| 210 | 213 | ||
| 214 | + | func (h *downloadHandler) emitProgressIfNeeded( | |
| 215 | + | ctx context.Context, | |
| 216 | + | id uint32, | |
| 217 | + | item purecef.DownloadItem, | |
| 218 | + | state cefDownloadState, | |
| 219 | + | ) { | |
| 220 | + | percent, ok := downloadProgressPercentFromItem(item) | |
| 221 | + | if !ok { | |
| 222 | + | return | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | h.mu.Lock() | |
| 226 | + | current, exists := h.active[id] | |
| 227 | + | if !exists { | |
| 228 | + | current = state | |
| 229 | + | } | |
| 230 | + | if current.lastProgressPercent == percent { | |
| 231 | + | h.mu.Unlock() | |
| 232 | + | return | |
| 233 | + | } | |
| 234 | + | current.lastProgressPercent = percent | |
| 235 | + | h.active[id] = current | |
| 236 | + | h.mu.Unlock() | |
| 237 | + | ||
| 238 | + | h.runtime.EmitProgress( | |
| 239 | + | ctx, | |
| 240 | + | current.filename, | |
| 241 | + | current.destination, | |
| 242 | + | float64(percent)/100, | |
| 243 | + | item.GetReceivedBytes(), | |
| 244 | + | item.GetTotalBytes(), | |
| 245 | + | ) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func downloadProgressPercentFromItem(item purecef.DownloadItem) (int32, bool) { | |
| 249 | + | if item == nil || item.IsComplete() || item.IsCanceled() || item.IsInterrupted() { | |
| 250 | + | return 0, false | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | percent := item.GetPercentComplete() | |
| 254 | + | if percent < 0 { | |
| 255 | + | total := item.GetTotalBytes() | |
| 256 | + | received := item.GetReceivedBytes() | |
| 257 | + | if total <= 0 || received <= 0 { | |
| 258 | + | return 0, false | |
| 259 | + | } | |
| 260 | + | percent = int32((received * 100) / total) | |
| 261 | + | } | |
| 262 | + | if percent <= 0 || percent >= 100 { | |
| 263 | + | return 0, false | |
| 264 | + | } | |
| 265 | + | return percent, true | |
| 266 | + | } | |
| 267 | + | ||
| 211 | 268 | func (h *downloadHandler) markFinished(id uint32) bool { | |
| 212 | 269 | h.mu.Lock() | |
| 213 | 270 | defer h.mu.Unlock() | |
| @@ -27,8 +27,9 @@ type downloadHandler struct { | |||||
| 27 | } | 27 | } | ||
| 28 | 28 | ||||
| 29 | type cefDownloadState struct { | 29 | type cefDownloadState struct { | ||
| 30 | - | filename string | 30 | + | filename string |
| 31 | - | destination string | 31 | + | destination string |
| 32 | + | lastProgressPercent int32 | |||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | type cefDownloadInterruptError struct { | 35 | type cefDownloadInterruptError struct { | ||
| @@ -134,8 +135,9 @@ func (h *downloadHandler) onBeforeDownload( | |||||
| 134 | 135 | ||||
| 135 | h.mu.Lock() | 136 | h.mu.Lock() | ||
| 136 | h.active[downloadItem.GetID()] = cefDownloadState{ | 137 | h.active[downloadItem.GetID()] = cefDownloadState{ | ||
| 137 | - | filename: output.Filename, | 138 | + | filename: output.Filename, |
| 138 | - | destination: output.DestinationPath, | 139 | + | destination: output.DestinationPath, |
| 140 | + | lastProgressPercent: -1, | |||
| 139 | } | 141 | } | ||
| 140 | h.mu.Unlock() | 142 | h.mu.Unlock() | ||
| 141 | 143 | ||||
| @@ -166,6 +168,7 @@ func (h *downloadHandler) onDownloadUpdated( | |||||
| 166 | } | 168 | } | ||
| 167 | 169 | ||||
| 168 | state := h.currentState(id, downloadItem) | 170 | state := h.currentState(id, downloadItem) | ||
| 171 | + | h.emitProgressIfNeeded(ctx, id, downloadItem, state) | |||
| 169 | 172 | ||||
| 170 | switch { | 173 | switch { | ||
| 171 | case downloadItem.IsComplete(): | 174 | case downloadItem.IsComplete(): | ||
| @@ -208,6 +211,60 @@ func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cef | |||||
| 208 | return state | 211 | return state | ||
| 209 | } | 212 | } | ||
| 210 | 213 | ||||
| 214 | + | func (h *downloadHandler) emitProgressIfNeeded( | |||
| 215 | + | ctx context.Context, | |||
| 216 | + | id uint32, | |||
| 217 | + | item purecef.DownloadItem, | |||
| 218 | + | state cefDownloadState, | |||
| 219 | + | ) { | |||
| 220 | + | percent, ok := downloadProgressPercentFromItem(item) | |||
| 221 | + | if !ok { | |||
| 222 | + | return | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | h.mu.Lock() | |||
| 226 | + | current, exists := h.active[id] | |||
| 227 | + | if !exists { | |||
| 228 | + | current = state | |||
| 229 | + | } | |||
| 230 | + | if current.lastProgressPercent == percent { | |||
| 231 | + | h.mu.Unlock() | |||
| 232 | + | return | |||
| 233 | + | } | |||
| 234 | + | current.lastProgressPercent = percent | |||
| 235 | + | h.active[id] = current | |||
| 236 | + | h.mu.Unlock() | |||
| 237 | + | ||||
| 238 | + | h.runtime.EmitProgress( | |||
| 239 | + | ctx, | |||
| 240 | + | current.filename, | |||
| 241 | + | current.destination, | |||
| 242 | + | float64(percent)/100, | |||
| 243 | + | item.GetReceivedBytes(), | |||
| 244 | + | item.GetTotalBytes(), | |||
| 245 | + | ) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func downloadProgressPercentFromItem(item purecef.DownloadItem) (int32, bool) { | |||
| 249 | + | if item == nil || item.IsComplete() || item.IsCanceled() || item.IsInterrupted() { | |||
| 250 | + | return 0, false | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | percent := item.GetPercentComplete() | |||
| 254 | + | if percent < 0 { | |||
| 255 | + | total := item.GetTotalBytes() | |||
| 256 | + | received := item.GetReceivedBytes() | |||
| 257 | + | if total <= 0 || received <= 0 { | |||
| 258 | + | return 0, false | |||
| 259 | + | } | |||
| 260 | + | percent = int32((received * 100) / total) | |||
| 261 | + | } | |||
| 262 | + | if percent <= 0 || percent >= 100 { | |||
| 263 | + | return 0, false | |||
| 264 | + | } | |||
| 265 | + | return percent, true | |||
| 266 | + | } | |||
| 267 | + | ||||
| 211 | func (h *downloadHandler) markFinished(id uint32) bool { | 268 | func (h *downloadHandler) markFinished(id uint32) bool { | ||
| 212 | h.mu.Lock() | 269 | h.mu.Lock() | ||
| 213 | defer h.mu.Unlock() | 270 | defer h.mu.Unlock() | ||
M internal/infrastructure/cef/download_handler_test.go +51 -13
| @@ -12,15 +12,18 @@ import ( | |||
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | type stubDownloadItem struct { | |
| 15 | - | id uint32 | |
| 16 | - | url string | |
| 17 | - | suggested string | |
| 18 | - | mimeType string | |
| 19 | - | fullPath string | |
| 20 | - | complete bool | |
| 21 | - | canceled bool | |
| 22 | - | interrupted bool | |
| 23 | - | reason purecef.DownloadInterruptReason | |
| 15 | + | id uint32 | |
| 16 | + | url string | |
| 17 | + | suggested string | |
| 18 | + | mimeType string | |
| 19 | + | fullPath string | |
| 20 | + | complete bool | |
| 21 | + | canceled bool | |
| 22 | + | interrupted bool | |
| 23 | + | reason purecef.DownloadInterruptReason | |
| 24 | + | percentComplete int32 | |
| 25 | + | totalBytes int64 | |
| 26 | + | receivedBytes int64 | |
| 24 | 27 | } | |
| 25 | 28 | ||
| 26 | 29 | func (s stubDownloadItem) IsValid() bool { return true } | |
| @@ -32,9 +35,9 @@ func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason { | |||
| 32 | 35 | return s.reason | |
| 33 | 36 | } | |
| 34 | 37 | func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 } | |
| 35 | - | func (s stubDownloadItem) GetPercentComplete() int32 { return 0 } | |
| 36 | - | func (s stubDownloadItem) GetTotalBytes() int64 { return 0 } | |
| 37 | - | func (s stubDownloadItem) GetReceivedBytes() int64 { return 0 } | |
| 38 | + | func (s stubDownloadItem) GetPercentComplete() int32 { return s.percentComplete } | |
| 39 | + | func (s stubDownloadItem) GetTotalBytes() int64 { return s.totalBytes } | |
| 40 | + | func (s stubDownloadItem) GetReceivedBytes() int64 { return s.receivedBytes } | |
| 38 | 41 | func (s stubDownloadItem) GetStartTime() uintptr { return 0 } | |
| 39 | 42 | func (s stubDownloadItem) GetEndTime() uintptr { return 0 } | |
| 40 | 43 | func (s stubDownloadItem) GetFullPath() string { return s.fullPath } | |
| @@ -120,7 +123,7 @@ func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) { | |||
| 120 | 123 | ||
| 121 | 124 | // Simulate a download that was tracked through onBeforeDownload. | |
| 122 | 125 | handler.mu.Lock() | |
| 123 | - | handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin"} | |
| 126 | + | handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1} | |
| 124 | 127 | handler.mu.Unlock() | |
| 125 | 128 | ||
| 126 | 129 | require.True(t, handler.markFinished(42), "first terminal event should succeed") | |
| @@ -133,6 +136,41 @@ func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) { | |||
| 133 | 136 | require.False(t, inActive) | |
| 134 | 137 | } | |
| 135 | 138 | ||
| 139 | + | func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) { | |
| 140 | + | ctx := context.Background() | |
| 141 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| 142 | + | events := &captureDownloadEvents{} | |
| 143 | + | handler := newDownloadHandler("/tmp/downloads", events, preparer) | |
| 144 | + | callback := &stubBeforeDownloadCallback{} | |
| 145 | + | ||
| 146 | + | item := stubDownloadItem{ | |
| 147 | + | id: 11, | |
| 148 | + | url: "https://example.com/archive.zip", | |
| 149 | + | suggested: "archive.zip", | |
| 150 | + | mimeType: "application/zip", | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback)) | |
| 154 | + | ||
| 155 | + | item.fullPath = callback.path | |
| 156 | + | item.percentComplete = 12 | |
| 157 | + | item.receivedBytes = 12 | |
| 158 | + | item.totalBytes = 100 | |
| 159 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 160 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 161 | + | ||
| 162 | + | item.percentComplete = 13 | |
| 163 | + | item.receivedBytes = 13 | |
| 164 | + | handler.onDownloadUpdated(ctx, item, nil) | |
| 165 | + | ||
| 166 | + | require.Len(t, events.events, 3) | |
| 167 | + | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | |
| 168 | + | require.Equal(t, port.DownloadEventProgress, events.events[1].Type) | |
| 169 | + | require.InEpsilon(t, 0.12, events.events[1].Progress, 0.0001) | |
| 170 | + | require.Equal(t, port.DownloadEventProgress, events.events[2].Type) | |
| 171 | + | require.InEpsilon(t, 0.13, events.events[2].Progress, 0.0001) | |
| 172 | + | } | |
| 173 | + | ||
| 136 | 174 | func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) { | |
| 137 | 175 | ctx := context.Background() | |
| 138 | 176 | preparer := usecase.NewPrepareDownloadUseCase(nil) | |
| @@ -12,15 +12,18 @@ import ( | |||||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | type stubDownloadItem struct { | 14 | type stubDownloadItem struct { | ||
| 15 | - | id uint32 | 15 | + | id uint32 |
| 16 | - | url string | 16 | + | url string |
| 17 | - | suggested string | 17 | + | suggested string |
| 18 | - | mimeType string | 18 | + | mimeType string |
| 19 | - | fullPath string | 19 | + | fullPath string |
| 20 | - | complete bool | 20 | + | complete bool |
| 21 | - | canceled bool | 21 | + | canceled bool |
| 22 | - | interrupted bool | 22 | + | interrupted bool |
| 23 | - | reason purecef.DownloadInterruptReason | 23 | + | reason purecef.DownloadInterruptReason |
| 24 | + | percentComplete int32 | |||
| 25 | + | totalBytes int64 | |||
| 26 | + | receivedBytes int64 | |||
| 24 | } | 27 | } | ||
| 25 | 28 | ||||
| 26 | func (s stubDownloadItem) IsValid() bool { return true } | 29 | func (s stubDownloadItem) IsValid() bool { return true } | ||
| @@ -32,9 +35,9 @@ func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason { | |||||
| 32 | return s.reason | 35 | return s.reason | ||
| 33 | } | 36 | } | ||
| 34 | func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 } | 37 | func (s stubDownloadItem) GetCurrentSpeed() int64 { return 0 } | ||
| 35 | - | func (s stubDownloadItem) GetPercentComplete() int32 { return 0 } | 38 | + | func (s stubDownloadItem) GetPercentComplete() int32 { return s.percentComplete } |
| 36 | - | func (s stubDownloadItem) GetTotalBytes() int64 { return 0 } | 39 | + | func (s stubDownloadItem) GetTotalBytes() int64 { return s.totalBytes } |
| 37 | - | func (s stubDownloadItem) GetReceivedBytes() int64 { return 0 } | 40 | + | func (s stubDownloadItem) GetReceivedBytes() int64 { return s.receivedBytes } |
| 38 | func (s stubDownloadItem) GetStartTime() uintptr { return 0 } | 41 | func (s stubDownloadItem) GetStartTime() uintptr { return 0 } | ||
| 39 | func (s stubDownloadItem) GetEndTime() uintptr { return 0 } | 42 | func (s stubDownloadItem) GetEndTime() uintptr { return 0 } | ||
| 40 | func (s stubDownloadItem) GetFullPath() string { return s.fullPath } | 43 | func (s stubDownloadItem) GetFullPath() string { return s.fullPath } | ||
| @@ -120,7 +123,7 @@ func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) { | |||||
| 120 | 123 | ||||
| 121 | // Simulate a download that was tracked through onBeforeDownload. | 124 | // Simulate a download that was tracked through onBeforeDownload. | ||
| 122 | handler.mu.Lock() | 125 | handler.mu.Lock() | ||
| 123 | - | handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin"} | 126 | + | handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1} |
| 124 | handler.mu.Unlock() | 127 | handler.mu.Unlock() | ||
| 125 | 128 | ||||
| 126 | require.True(t, handler.markFinished(42), "first terminal event should succeed") | 129 | require.True(t, handler.markFinished(42), "first terminal event should succeed") | ||
| @@ -133,6 +136,41 @@ func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) { | |||||
| 133 | require.False(t, inActive) | 136 | require.False(t, inActive) | ||
| 134 | } | 137 | } | ||
| 135 | 138 | ||||
| 139 | + | func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) { | |||
| 140 | + | ctx := context.Background() | |||
| 141 | + | preparer := usecase.NewPrepareDownloadUseCase(nil) | |||
| 142 | + | events := &captureDownloadEvents{} | |||
| 143 | + | handler := newDownloadHandler("/tmp/downloads", events, preparer) | |||
| 144 | + | callback := &stubBeforeDownloadCallback{} | |||
| 145 | + | ||||
| 146 | + | item := stubDownloadItem{ | |||
| 147 | + | id: 11, | |||
| 148 | + | url: "https://example.com/archive.zip", | |||
| 149 | + | suggested: "archive.zip", | |||
| 150 | + | mimeType: "application/zip", | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback)) | |||
| 154 | + | ||||
| 155 | + | item.fullPath = callback.path | |||
| 156 | + | item.percentComplete = 12 | |||
| 157 | + | item.receivedBytes = 12 | |||
| 158 | + | item.totalBytes = 100 | |||
| 159 | + | handler.onDownloadUpdated(ctx, item, nil) | |||
| 160 | + | handler.onDownloadUpdated(ctx, item, nil) | |||
| 161 | + | ||||
| 162 | + | item.percentComplete = 13 | |||
| 163 | + | item.receivedBytes = 13 | |||
| 164 | + | handler.onDownloadUpdated(ctx, item, nil) | |||
| 165 | + | ||||
| 166 | + | require.Len(t, events.events, 3) | |||
| 167 | + | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | |||
| 168 | + | require.Equal(t, port.DownloadEventProgress, events.events[1].Type) | |||
| 169 | + | require.InEpsilon(t, 0.12, events.events[1].Progress, 0.0001) | |||
| 170 | + | require.Equal(t, port.DownloadEventProgress, events.events[2].Type) | |||
| 171 | + | require.InEpsilon(t, 0.13, events.events[2].Progress, 0.0001) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 136 | func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) { | 174 | func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) { | ||
| 137 | ctx := context.Background() | 175 | ctx := context.Background() | ||
| 138 | preparer := usecase.NewPrepareDownloadUseCase(nil) | 176 | preparer := usecase.NewPrepareDownloadUseCase(nil) | ||
M internal/infrastructure/downloadruntime/runtime.go +22 -0
| @@ -90,6 +90,28 @@ func (r *Runtime) EmitStarted(ctx context.Context, output *port.DownloadPrepareO | |||
| 90 | 90 | Msg("download started") | |
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | + | func (r *Runtime) EmitProgress( | |
| 94 | + | ctx context.Context, | |
| 95 | + | filename, destination string, | |
| 96 | + | progress float64, | |
| 97 | + | bytesReceived, bytesTotal int64, | |
| 98 | + | ) { | |
| 99 | + | r.mu.RLock() | |
| 100 | + | eventHandler := r.eventHandler | |
| 101 | + | r.mu.RUnlock() | |
| 102 | + | ||
| 103 | + | if eventHandler != nil { | |
| 104 | + | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |
| 105 | + | Type: port.DownloadEventProgress, | |
| 106 | + | Filename: filename, | |
| 107 | + | Destination: destination, | |
| 108 | + | Progress: progress, | |
| 109 | + | BytesReceived: bytesReceived, | |
| 110 | + | BytesTotal: bytesTotal, | |
| 111 | + | }) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 93 | 115 | func (r *Runtime) EmitFinished(ctx context.Context, filename, destination string) { | |
| 94 | 116 | r.mu.RLock() | |
| 95 | 117 | eventHandler := r.eventHandler | |
| @@ -90,6 +90,28 @@ func (r *Runtime) EmitStarted(ctx context.Context, output *port.DownloadPrepareO | |||||
| 90 | Msg("download started") | 90 | Msg("download started") | ||
| 91 | } | 91 | } | ||
| 92 | 92 | ||||
| 93 | + | func (r *Runtime) EmitProgress( | |||
| 94 | + | ctx context.Context, | |||
| 95 | + | filename, destination string, | |||
| 96 | + | progress float64, | |||
| 97 | + | bytesReceived, bytesTotal int64, | |||
| 98 | + | ) { | |||
| 99 | + | r.mu.RLock() | |||
| 100 | + | eventHandler := r.eventHandler | |||
| 101 | + | r.mu.RUnlock() | |||
| 102 | + | ||||
| 103 | + | if eventHandler != nil { | |||
| 104 | + | eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{ | |||
| 105 | + | Type: port.DownloadEventProgress, | |||
| 106 | + | Filename: filename, | |||
| 107 | + | Destination: destination, | |||
| 108 | + | Progress: progress, | |||
| 109 | + | BytesReceived: bytesReceived, | |||
| 110 | + | BytesTotal: bytesTotal, | |||
| 111 | + | }) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 93 | func (r *Runtime) EmitFinished(ctx context.Context, filename, destination string) { | 115 | func (r *Runtime) EmitFinished(ctx context.Context, filename, destination string) { | ||
| 94 | r.mu.RLock() | 116 | r.mu.RLock() | ||
| 95 | eventHandler := r.eventHandler | 117 | eventHandler := r.eventHandler | ||
M internal/infrastructure/downloadruntime/runtime_test.go +7 -2
| @@ -37,11 +37,16 @@ func TestRuntimeResolveDestinationAndEvents(t *testing.T) { | |||
| 37 | 37 | require.NoError(t, err) | |
| 38 | 38 | require.Equal(t, "artifact.pdf", output.Filename) | |
| 39 | 39 | runtime.EmitStarted(ctx, output) | |
| 40 | + | runtime.EmitProgress(ctx, output.Filename, output.DestinationPath, 0.42, 42, 100) | |
| 40 | 41 | runtime.EmitFinished(ctx, output.Filename, output.DestinationPath) | |
| 41 | 42 | ||
| 42 | - | require.Len(t, events.events, 2) | |
| 43 | + | require.Len(t, events.events, 3) | |
| 43 | 44 | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | |
| 44 | - | require.Equal(t, port.DownloadEventFinished, events.events[1].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) | |
| 45 | 50 | } | |
| 46 | 51 | ||
| 47 | 52 | func TestNewRuntime_NilPreparer_NoPanic(t *testing.T) { | |
| @@ -37,11 +37,16 @@ func TestRuntimeResolveDestinationAndEvents(t *testing.T) { | |||||
| 37 | require.NoError(t, err) | 37 | require.NoError(t, err) | ||
| 38 | require.Equal(t, "artifact.pdf", output.Filename) | 38 | require.Equal(t, "artifact.pdf", output.Filename) | ||
| 39 | runtime.EmitStarted(ctx, output) | 39 | runtime.EmitStarted(ctx, output) | ||
| 40 | + | runtime.EmitProgress(ctx, output.Filename, output.DestinationPath, 0.42, 42, 100) | |||
| 40 | runtime.EmitFinished(ctx, output.Filename, output.DestinationPath) | 41 | runtime.EmitFinished(ctx, output.Filename, output.DestinationPath) | ||
| 41 | 42 | ||||
| 42 | - | require.Len(t, events.events, 2) | 43 | + | require.Len(t, events.events, 3) |
| 43 | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | 44 | require.Equal(t, port.DownloadEventStarted, events.events[0].Type) | ||
| 44 | - | require.Equal(t, port.DownloadEventFinished, events.events[1].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) | |||
| 45 | } | 50 | } | ||
| 46 | 51 | ||||
| 47 | func TestNewRuntime_NilPreparer_NoPanic(t *testing.T) { | 52 | func TestNewRuntime_NilPreparer_NoPanic(t *testing.T) { | ||
M internal/infrastructure/webkit/download_handler.go +100 -22
| @@ -34,13 +34,18 @@ func NewDownloadHandler( | |||
| 34 | 34 | // downloadState tracks the state of a single download to prevent duplicate notifications. | |
| 35 | 35 | // This is allocated per-download to ensure proper isolation between concurrent downloads. | |
| 36 | 36 | type downloadState struct { | |
| 37 | - | failed bool | |
| 38 | - | mu sync.Mutex | |
| 37 | + | failed bool | |
| 38 | + | filename string | |
| 39 | + | destination string | |
| 40 | + | lastProgressPercent int | |
| 41 | + | mu sync.Mutex | |
| 39 | 42 | } | |
| 40 | 43 | ||
| 41 | 44 | // HandleDownload sets up signal handlers for a new download. | |
| 42 | 45 | func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) { | |
| 43 | - | log := logging.FromContext(ctx) | |
| 46 | + | if download == nil { | |
| 47 | + | return | |
| 48 | + | } | |
| 44 | 49 | ||
| 45 | 50 | h.mu.RLock() | |
| 46 | 51 | runtime := h.runtime | |
| @@ -48,11 +53,29 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D | |||
| 48 | 53 | ||
| 49 | 54 | // Track download state in a dedicated struct to ensure proper isolation. | |
| 50 | 55 | // Each download gets its own state to prevent interference between concurrent downloads. | |
| 51 | - | state := &downloadState{} | |
| 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 | + | } | |
| 52 | 70 | ||
| 53 | - | // Handle decide-destination signal to set download path. | |
| 54 | - | //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously) | |
| 55 | - | decideDestCb := func(d webkit.Download, suggestedFilename string) bool { | |
| 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) | |
| 77 | + | ||
| 78 | + | return func(d webkit.Download, suggestedFilename string) bool { | |
| 56 | 79 | var response port.DownloadResponse | |
| 57 | 80 | if resp := d.GetResponse(); resp != nil { | |
| 58 | 81 | response = &uriResponseAdapter{resp: resp} | |
| @@ -71,48 +94,103 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D | |||
| 71 | 94 | Str("destPath", output.DestinationPath). | |
| 72 | 95 | Msg("setting download destination") | |
| 73 | 96 | ||
| 74 | - | // WebKit expects an absolute file path for the destination. | |
| 75 | 97 | d.SetDestination(output.DestinationPath) | |
| 76 | 98 | ||
| 99 | + | state.mu.Lock() | |
| 100 | + | state.filename = output.Filename | |
| 101 | + | state.destination = output.DestinationPath | |
| 102 | + | state.lastProgressPercent = -1 | |
| 103 | + | state.mu.Unlock() | |
| 104 | + | ||
| 77 | 105 | runtime.EmitStarted(ctx, output) | |
| 106 | + | emitWebKitDownloadProgress(ctx, runtime, state, d) | |
| 107 | + | return false | |
| 108 | + | } | |
| 109 | + | } | |
| 78 | 110 | ||
| 79 | - | 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) | |
| 80 | 118 | } | |
| 81 | - | download.ConnectDecideDestination(&decideDestCb) | |
| 119 | + | } | |
| 82 | 120 | ||
| 83 | - | // Handle failed signal. | |
| 84 | - | 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) { | |
| 85 | 127 | state.mu.Lock() | |
| 86 | 128 | state.failed = true | |
| 87 | 129 | state.mu.Unlock() | |
| 88 | 130 | ||
| 89 | 131 | dest := d.GetDestination() | |
| 90 | 132 | filename := downloadutil.ExtractFilenameFromDestination(dest) | |
| 91 | - | ||
| 92 | - | // Create error for the failed download. | |
| 93 | 133 | downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo()) | |
| 94 | - | ||
| 95 | 134 | runtime.EmitFailed(ctx, filename, dest, downloadErr) | |
| 96 | 135 | } | |
| 97 | - | download.ConnectFailed(&failedCb) | |
| 136 | + | } | |
| 98 | 137 | ||
| 99 | - | // Handle finished signal. | |
| 100 | - | 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) { | |
| 101 | 144 | state.mu.Lock() | |
| 102 | 145 | failed := state.failed | |
| 103 | 146 | state.mu.Unlock() | |
| 104 | - | ||
| 105 | - | // Don't send finished event if we already sent a failed event. | |
| 106 | 147 | if failed { | |
| 107 | 148 | return | |
| 108 | 149 | } | |
| 109 | 150 | ||
| 110 | 151 | dest := d.GetDestination() | |
| 111 | 152 | filename := downloadutil.ExtractFilenameFromDestination(dest) | |
| 112 | - | ||
| 113 | 153 | runtime.EmitFinished(ctx, filename, dest) | |
| 114 | 154 | } | |
| 115 | - | download.ConnectFinished(&finishedCb) | |
| 155 | + | } | |
| 156 | + | ||
| 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 | + | } | |
| 175 | + | ||
| 176 | + | progress := d.GetEstimatedProgress() | |
| 177 | + | if progress <= 0 || progress >= 1 { | |
| 178 | + | return | |
| 179 | + | } | |
| 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) | |
| 116 | 194 | } | |
| 117 | 195 | ||
| 118 | 196 | // SetDownloadPath updates the download directory. | |
| @@ -34,13 +34,18 @@ func NewDownloadHandler( | |||||
| 34 | // 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. | ||
| 35 | // 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. | ||
| 36 | type downloadState struct { | 36 | type downloadState struct { | ||
| 37 | - | failed bool | 37 | + | failed bool |
| 38 | - | mu sync.Mutex | 38 | + | filename string |
| 39 | + | destination string | |||
| 40 | + | lastProgressPercent int | |||
| 41 | + | mu sync.Mutex | |||
| 39 | } | 42 | } | ||
| 40 | 43 | ||||
| 41 | // HandleDownload sets up signal handlers for a new download. | 44 | // HandleDownload sets up signal handlers for a new download. | ||
| 42 | func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) { | 45 | func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) { | ||
| 43 | - | log := logging.FromContext(ctx) | 46 | + | if download == nil { |
| 47 | + | return | |||
| 48 | + | } | |||
| 44 | 49 | ||||
| 45 | h.mu.RLock() | 50 | h.mu.RLock() | ||
| 46 | runtime := h.runtime | 51 | runtime := h.runtime | ||
| @@ -48,11 +53,29 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D | |||||
| 48 | 53 | ||||
| 49 | // Track download state in a dedicated struct to ensure proper isolation. | 54 | // Track download state in a dedicated struct to ensure proper isolation. | ||
| 50 | // 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. | ||
| 51 | - | state := &downloadState{} | 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 | + | } | |||
| 52 | 70 | ||||
| 53 | - | // Handle decide-destination signal to set download path. | 71 | + | func makeDecideDestinationCallback( |
| 54 | - | //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously) | 72 | + | ctx context.Context, |
| 55 | - | decideDestCb := func(d webkit.Download, suggestedFilename string) bool { | 73 | + | runtime *downloadruntime.Runtime, |
| 74 | + | state *downloadState, | |||
| 75 | + | ) func(webkit.Download, string) bool { | |||
| 76 | + | log := logging.FromContext(ctx) | |||
| 77 | + | ||||
| 78 | + | return func(d webkit.Download, suggestedFilename string) bool { | |||
| 56 | var response port.DownloadResponse | 79 | var response port.DownloadResponse | ||
| 57 | if resp := d.GetResponse(); resp != nil { | 80 | if resp := d.GetResponse(); resp != nil { | ||
| 58 | response = &uriResponseAdapter{resp: resp} | 81 | response = &uriResponseAdapter{resp: resp} | ||
| @@ -71,48 +94,103 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D | |||||
| 71 | Str("destPath", output.DestinationPath). | 94 | Str("destPath", output.DestinationPath). | ||
| 72 | Msg("setting download destination") | 95 | Msg("setting download destination") | ||
| 73 | 96 | ||||
| 74 | - | // WebKit expects an absolute file path for the destination. | |||
| 75 | d.SetDestination(output.DestinationPath) | 97 | d.SetDestination(output.DestinationPath) | ||
| 76 | 98 | ||||
| 99 | + | state.mu.Lock() | |||
| 100 | + | state.filename = output.Filename | |||
| 101 | + | state.destination = output.DestinationPath | |||
| 102 | + | state.lastProgressPercent = -1 | |||
| 103 | + | state.mu.Unlock() | |||
| 104 | + | ||||
| 77 | runtime.EmitStarted(ctx, output) | 105 | runtime.EmitStarted(ctx, output) | ||
| 106 | + | emitWebKitDownloadProgress(ctx, runtime, state, d) | |||
| 107 | + | return false | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 78 | 110 | ||||
| 79 | - | 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) | |||
| 80 | } | 118 | } | ||
| 81 | - | download.ConnectDecideDestination(&decideDestCb) | 119 | + | } |
| 82 | 120 | ||||
| 83 | - | // Handle failed signal. | 121 | + | func makeFailedCallback( |
| 84 | - | 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) { | |||
| 85 | state.mu.Lock() | 127 | state.mu.Lock() | ||
| 86 | state.failed = true | 128 | state.failed = true | ||
| 87 | state.mu.Unlock() | 129 | state.mu.Unlock() | ||
| 88 | 130 | ||||
| 89 | dest := d.GetDestination() | 131 | dest := d.GetDestination() | ||
| 90 | filename := downloadutil.ExtractFilenameFromDestination(dest) | 132 | filename := downloadutil.ExtractFilenameFromDestination(dest) | ||
| 91 | - | ||||
| 92 | - | // Create error for the failed download. | |||
| 93 | downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo()) | 133 | downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo()) | ||
| 94 | - | ||||
| 95 | runtime.EmitFailed(ctx, filename, dest, downloadErr) | 134 | runtime.EmitFailed(ctx, filename, dest, downloadErr) | ||
| 96 | } | 135 | } | ||
| 97 | - | download.ConnectFailed(&failedCb) | 136 | + | } |
| 98 | 137 | ||||
| 99 | - | // Handle finished signal. | 138 | + | func makeFinishedCallback( |
| 100 | - | 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) { | |||
| 101 | state.mu.Lock() | 144 | state.mu.Lock() | ||
| 102 | failed := state.failed | 145 | failed := state.failed | ||
| 103 | state.mu.Unlock() | 146 | state.mu.Unlock() | ||
| 104 | - | ||||
| 105 | - | // Don't send finished event if we already sent a failed event. | |||
| 106 | if failed { | 147 | if failed { | ||
| 107 | return | 148 | return | ||
| 108 | } | 149 | } | ||
| 109 | 150 | ||||
| 110 | dest := d.GetDestination() | 151 | dest := d.GetDestination() | ||
| 111 | filename := downloadutil.ExtractFilenameFromDestination(dest) | 152 | filename := downloadutil.ExtractFilenameFromDestination(dest) | ||
| 112 | - | ||||
| 113 | runtime.EmitFinished(ctx, filename, dest) | 153 | runtime.EmitFinished(ctx, filename, dest) | ||
| 114 | } | 154 | } | ||
| 115 | - | download.ConnectFinished(&finishedCb) | 155 | + | } |
| 156 | + | ||||
| 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 | + | } | |||
| 175 | + | ||||
| 176 | + | progress := d.GetEstimatedProgress() | |||
| 177 | + | if progress <= 0 || progress >= 1 { | |||
| 178 | + | return | |||
| 179 | + | } | |||
| 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) | |||
| 116 | } | 194 | } | ||
| 117 | 195 | ||||
| 118 | // SetDownloadPath updates the download directory. | 196 | // SetDownloadPath updates the download directory. | ||
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 | + | } | |||