detached e7be50f

Share download runtime across engines

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

Co-authored-by: bnema <5212137+bnema@users.noreply.github.com>
commit
e7be50f7dad96eaa074c526b68dfda36432dfaa3
author
copilot-swe-agent[bot] · · 2026-04-14 13:04 UTC
committer
brice · · 2026-04-19 19:06 UTC
parent
84a8a0c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +199 -138
M internal/infrastructure/cef/download_handler.go +12 -70
@@ -4,7 +4,6 @@ import (
44 "context"
55 "fmt"
66 "net/http"
7- "os"
87 "strings"
98 "sync"
109
@@ -12,18 +11,14 @@ import (
1211
1312 "github.com/bnema/dumber/internal/application/port"
1413 downloadutil "github.com/bnema/dumber/internal/domain/download"
14+ "github.com/bnema/dumber/internal/infrastructure/downloadruntime"
1515 "github.com/bnema/dumber/internal/logging"
1616 )
1717
18-const cefDownloadDirPerm = 0o755
19-
2018 type downloadHandler struct {
21- downloadPath string
22- eventHandler port.DownloadEventHandler
23- preparer port.DownloadPreparer
24-
25- mu sync.Mutex
26- active map[uint32]cefDownloadState
19+ runtime *downloadruntime.Runtime
20+ mu sync.Mutex
21+ active map[uint32]cefDownloadState
2722 }
2823
2924 type cefDownloadState struct {
@@ -41,15 +36,9 @@ func newDownloadHandler(
4136 eventHandler port.DownloadEventHandler,
4237 preparer port.DownloadPreparer,
4338 ) *downloadHandler {
44- if preparer == nil {
45- panic("preparer is required")
46- }
47-
4839 return &downloadHandler{
49- downloadPath: downloadPath,
50- eventHandler: eventHandler,
51- preparer: preparer,
52- active: make(map[uint32]cefDownloadState),
40+ runtime: downloadruntime.New(downloadPath, eventHandler, preparer),
41+ active: make(map[uint32]cefDownloadState),
5342 }
5443 }
5544
@@ -69,19 +58,13 @@ func (h *downloadHandler) onBeforeDownload(
6958 }
7059
7160 log := logging.FromContext(ctx)
72- downloadPath, eventHandler, preparer := h.snapshot()
73- if err := os.MkdirAll(downloadPath, cefDownloadDirPerm); err != nil {
74- log.Error().Err(err).Str("path", downloadPath).Msg("cef: failed to create download directory")
61+ output, err := h.runtime.ResolveDestination(ctx, suggestedName, &cefDownloadResponseAdapter{item: downloadItem})
62+ if err != nil {
63+ log.Error().Err(err).Msg("cef: failed to prepare download destination")
7564 callback.Cont("", 1)
7665 return true
7766 }
7867
79- output := preparer.Execute(ctx, port.DownloadPrepareInput{
80- SuggestedFilename: suggestedName,
81- Response: &cefDownloadResponseAdapter{item: downloadItem},
82- DownloadDir: downloadPath,
83- })
84-
8568 h.mu.Lock()
8669 h.active[downloadItem.GetID()] = cefDownloadState{
8770 filename: output.Filename,
@@ -90,20 +73,7 @@ func (h *downloadHandler) onBeforeDownload(
9073 h.mu.Unlock()
9174
9275 callback.Cont(output.DestinationPath, 0)
93-
94- if eventHandler != nil {
95- eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
96- Type: port.DownloadEventStarted,
97- Filename: output.Filename,
98- Destination: output.DestinationPath,
99- })
100- }
101-
102- log.Info().
103- Uint32("download_id", downloadItem.GetID()).
104- Str("filename", output.Filename).
105- Str("destination", output.DestinationPath).
106- Msg("cef: download started")
76+ h.runtime.EmitStarted(ctx, output)
10777
10878 return true
10979 }
@@ -118,7 +88,6 @@ func (h *downloadHandler) onDownloadUpdated(
11888 return
11989 }
12090
121- log := logging.FromContext(ctx)
12291 id := downloadItem.GetID()
12392 state := h.currentState(id, downloadItem)
12493
@@ -127,17 +96,7 @@ func (h *downloadHandler) onDownloadUpdated(
12796 if !h.markFinished(id, state) {
12897 return
12998 }
130- if h.eventHandler != nil {
131- h.eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
132- Type: port.DownloadEventFinished,
133- Filename: state.filename,
134- Destination: state.destination,
135- })
136- }
137- log.Info().
138- Uint32("download_id", id).
139- Str("filename", state.filename).
140- Msg("cef: download finished")
99+ h.runtime.EmitFinished(ctx, state.filename, state.destination)
141100 case downloadItem.IsCanceled() || downloadItem.IsInterrupted():
142101 if !h.markFinished(id, state) {
143102 return
@@ -148,27 +107,10 @@ func (h *downloadHandler) onDownloadUpdated(
148107 } else if downloadItem.IsCanceled() {
149108 err = fmt.Errorf("download canceled: %s", state.filename)
150109 }
151- if h.eventHandler != nil {
152- h.eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
153- Type: port.DownloadEventFailed,
154- Filename: state.filename,
155- Destination: state.destination,
156- Error: err,
157- })
158- }
159- log.Warn().
160- Uint32("download_id", id).
161- Err(err).
162- Msg("cef: download failed")
110+ h.runtime.EmitFailed(ctx, state.filename, state.destination, err)
163111 }
164112 }
165113
166-func (h *downloadHandler) snapshot() (string, port.DownloadEventHandler, port.DownloadPreparer) {
167- h.mu.Lock()
168- defer h.mu.Unlock()
169- return h.downloadPath, h.eventHandler, h.preparer
170-}
171-
172114 func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cefDownloadState {
173115 h.mu.Lock()
174116 defer h.mu.Unlock()
@@ -4,7 +4,6 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "net/http"6 "net/http"
7- "os"
8 "strings"7 "strings"
9 "sync"8 "sync"
10 9
@@ -12,18 +11,14 @@ import (
12 11
13 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
14 downloadutil "github.com/bnema/dumber/internal/domain/download"13 downloadutil "github.com/bnema/dumber/internal/domain/download"
14+ "github.com/bnema/dumber/internal/infrastructure/downloadruntime"
15 "github.com/bnema/dumber/internal/logging"15 "github.com/bnema/dumber/internal/logging"
16 )16 )
17 17
18-const cefDownloadDirPerm = 0o755
19-
20 type downloadHandler struct {18 type downloadHandler struct {
21- downloadPath string19+ runtime *downloadruntime.Runtime
22- eventHandler port.DownloadEventHandler20+ mu sync.Mutex
23- preparer port.DownloadPreparer21+ active map[uint32]cefDownloadState
24-
25- mu sync.Mutex
26- active map[uint32]cefDownloadState
27 }22 }
28 23
29 type cefDownloadState struct {24 type cefDownloadState struct {
@@ -41,15 +36,9 @@ func newDownloadHandler(
41 eventHandler port.DownloadEventHandler,36 eventHandler port.DownloadEventHandler,
42 preparer port.DownloadPreparer,37 preparer port.DownloadPreparer,
43 ) *downloadHandler {38 ) *downloadHandler {
44- if preparer == nil {
45- panic("preparer is required")
46- }
47-
48 return &downloadHandler{39 return &downloadHandler{
49- downloadPath: downloadPath,40+ runtime: downloadruntime.New(downloadPath, eventHandler, preparer),
50- eventHandler: eventHandler,41+ active: make(map[uint32]cefDownloadState),
51- preparer: preparer,
52- active: make(map[uint32]cefDownloadState),
53 }42 }
54 }43 }
55 44
@@ -69,19 +58,13 @@ func (h *downloadHandler) onBeforeDownload(
69 }58 }
70 59
71 log := logging.FromContext(ctx)60 log := logging.FromContext(ctx)
72- downloadPath, eventHandler, preparer := h.snapshot()61+ output, err := h.runtime.ResolveDestination(ctx, suggestedName, &cefDownloadResponseAdapter{item: downloadItem})
73- if err := os.MkdirAll(downloadPath, cefDownloadDirPerm); err != nil {62+ if err != nil {
74- log.Error().Err(err).Str("path", downloadPath).Msg("cef: failed to create download directory")63+ log.Error().Err(err).Msg("cef: failed to prepare download destination")
75 callback.Cont("", 1)64 callback.Cont("", 1)
76 return true65 return true
77 }66 }
78 67
79- output := preparer.Execute(ctx, port.DownloadPrepareInput{
80- SuggestedFilename: suggestedName,
81- Response: &cefDownloadResponseAdapter{item: downloadItem},
82- DownloadDir: downloadPath,
83- })
84-
85 h.mu.Lock()68 h.mu.Lock()
86 h.active[downloadItem.GetID()] = cefDownloadState{69 h.active[downloadItem.GetID()] = cefDownloadState{
87 filename: output.Filename,70 filename: output.Filename,
@@ -90,20 +73,7 @@ func (h *downloadHandler) onBeforeDownload(
90 h.mu.Unlock()73 h.mu.Unlock()
91 74
92 callback.Cont(output.DestinationPath, 0)75 callback.Cont(output.DestinationPath, 0)
93-76+ h.runtime.EmitStarted(ctx, output)
94- if eventHandler != nil {
95- eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
96- Type: port.DownloadEventStarted,
97- Filename: output.Filename,
98- Destination: output.DestinationPath,
99- })
100- }
101-
102- log.Info().
103- Uint32("download_id", downloadItem.GetID()).
104- Str("filename", output.Filename).
105- Str("destination", output.DestinationPath).
106- Msg("cef: download started")
107 77
108 return true78 return true
109 }79 }
@@ -118,7 +88,6 @@ func (h *downloadHandler) onDownloadUpdated(
118 return88 return
119 }89 }
120 90
121- log := logging.FromContext(ctx)
122 id := downloadItem.GetID()91 id := downloadItem.GetID()
123 state := h.currentState(id, downloadItem)92 state := h.currentState(id, downloadItem)
124 93
@@ -127,17 +96,7 @@ func (h *downloadHandler) onDownloadUpdated(
127 if !h.markFinished(id, state) {96 if !h.markFinished(id, state) {
128 return97 return
129 }98 }
130- if h.eventHandler != nil {99+ h.runtime.EmitFinished(ctx, state.filename, state.destination)
131- h.eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
132- Type: port.DownloadEventFinished,
133- Filename: state.filename,
134- Destination: state.destination,
135- })
136- }
137- log.Info().
138- Uint32("download_id", id).
139- Str("filename", state.filename).
140- Msg("cef: download finished")
141 case downloadItem.IsCanceled() || downloadItem.IsInterrupted():100 case downloadItem.IsCanceled() || downloadItem.IsInterrupted():
142 if !h.markFinished(id, state) {101 if !h.markFinished(id, state) {
143 return102 return
@@ -148,27 +107,10 @@ func (h *downloadHandler) onDownloadUpdated(
148 } else if downloadItem.IsCanceled() {107 } else if downloadItem.IsCanceled() {
149 err = fmt.Errorf("download canceled: %s", state.filename)108 err = fmt.Errorf("download canceled: %s", state.filename)
150 }109 }
151- if h.eventHandler != nil {110+ h.runtime.EmitFailed(ctx, state.filename, state.destination, err)
152- h.eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
153- Type: port.DownloadEventFailed,
154- Filename: state.filename,
155- Destination: state.destination,
156- Error: err,
157- })
158- }
159- log.Warn().
160- Uint32("download_id", id).
161- Err(err).
162- Msg("cef: download failed")
163 }111 }
164 }112 }
165 113
166-func (h *downloadHandler) snapshot() (string, port.DownloadEventHandler, port.DownloadPreparer) {
167- h.mu.Lock()
168- defer h.mu.Unlock()
169- return h.downloadPath, h.eventHandler, h.preparer
170-}
171-
172 func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cefDownloadState {114 func (h *downloadHandler) currentState(id uint32, item purecef.DownloadItem) cefDownloadState {
173 h.mu.Lock()115 h.mu.Lock()
174 defer h.mu.Unlock()116 defer h.mu.Unlock()
A internal/infrastructure/downloadruntime/runtime.go +123 -0
@@ -0,0 +1,123 @@
1+package downloadruntime
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+const dirPerm = 0o755
14+
15+// Runtime shares common download preparation and event emission logic across engines.
16+type Runtime struct {
17+ mu sync.RWMutex
18+ downloadPath string
19+ eventHandler port.DownloadEventHandler
20+ preparer port.DownloadPreparer
21+}
22+
23+func New(downloadPath string, eventHandler port.DownloadEventHandler, preparer port.DownloadPreparer) *Runtime {
24+ if preparer == nil {
25+ panic("preparer is required")
26+ }
27+
28+ return &Runtime{
29+ downloadPath: downloadPath,
30+ eventHandler: eventHandler,
31+ preparer: preparer,
32+ }
33+}
34+
35+func (r *Runtime) SetDownloadPath(path string) {
36+ r.mu.Lock()
37+ defer r.mu.Unlock()
38+ r.downloadPath = path
39+}
40+
41+func (r *Runtime) ResolveDestination(
42+ ctx context.Context,
43+ suggestedFilename string,
44+ response port.DownloadResponse,
45+) (*port.DownloadPrepareOutput, error) {
46+ r.mu.RLock()
47+ downloadPath := r.downloadPath
48+ preparer := r.preparer
49+ r.mu.RUnlock()
50+
51+ if err := os.MkdirAll(downloadPath, dirPerm); err != nil {
52+ return nil, fmt.Errorf("failed to create download directory %q: %w", downloadPath, err)
53+ }
54+
55+ return preparer.Execute(ctx, port.DownloadPrepareInput{
56+ SuggestedFilename: suggestedFilename,
57+ Response: response,
58+ DownloadDir: downloadPath,
59+ }), nil
60+}
61+
62+func (r *Runtime) EmitStarted(ctx context.Context, output *port.DownloadPrepareOutput) {
63+ if output == nil {
64+ return
65+ }
66+
67+ r.mu.RLock()
68+ eventHandler := r.eventHandler
69+ r.mu.RUnlock()
70+
71+ if eventHandler != nil {
72+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
73+ Type: port.DownloadEventStarted,
74+ Filename: output.Filename,
75+ Destination: output.DestinationPath,
76+ })
77+ }
78+
79+ logging.FromContext(ctx).Info().
80+ Str("filename", output.Filename).
81+ Str("destination", output.DestinationPath).
82+ Msg("download started")
83+}
84+
85+func (r *Runtime) EmitFinished(ctx context.Context, filename, destination string) {
86+ r.mu.RLock()
87+ eventHandler := r.eventHandler
88+ r.mu.RUnlock()
89+
90+ if eventHandler != nil {
91+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
92+ Type: port.DownloadEventFinished,
93+ Filename: filename,
94+ Destination: destination,
95+ })
96+ }
97+
98+ logging.FromContext(ctx).Info().
99+ Str("filename", filename).
100+ Str("destination", destination).
101+ Msg("download finished")
102+}
103+
104+func (r *Runtime) EmitFailed(ctx context.Context, filename, destination string, err error) {
105+ r.mu.RLock()
106+ eventHandler := r.eventHandler
107+ r.mu.RUnlock()
108+
109+ if eventHandler != nil {
110+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
111+ Type: port.DownloadEventFailed,
112+ Filename: filename,
113+ Destination: destination,
114+ Error: err,
115+ })
116+ }
117+
118+ logging.FromContext(ctx).Warn().
119+ Err(err).
120+ Str("filename", filename).
121+ Str("destination", destination).
122+ Msg("download failed")
123+}
@@ -0,0 +1,123 @@
1+package downloadruntime
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+)
12+
13+const dirPerm = 0o755
14+
15+// Runtime shares common download preparation and event emission logic across engines.
16+type Runtime struct {
17+ mu sync.RWMutex
18+ downloadPath string
19+ eventHandler port.DownloadEventHandler
20+ preparer port.DownloadPreparer
21+}
22+
23+func New(downloadPath string, eventHandler port.DownloadEventHandler, preparer port.DownloadPreparer) *Runtime {
24+ if preparer == nil {
25+ panic("preparer is required")
26+ }
27+
28+ return &Runtime{
29+ downloadPath: downloadPath,
30+ eventHandler: eventHandler,
31+ preparer: preparer,
32+ }
33+}
34+
35+func (r *Runtime) SetDownloadPath(path string) {
36+ r.mu.Lock()
37+ defer r.mu.Unlock()
38+ r.downloadPath = path
39+}
40+
41+func (r *Runtime) ResolveDestination(
42+ ctx context.Context,
43+ suggestedFilename string,
44+ response port.DownloadResponse,
45+) (*port.DownloadPrepareOutput, error) {
46+ r.mu.RLock()
47+ downloadPath := r.downloadPath
48+ preparer := r.preparer
49+ r.mu.RUnlock()
50+
51+ if err := os.MkdirAll(downloadPath, dirPerm); err != nil {
52+ return nil, fmt.Errorf("failed to create download directory %q: %w", downloadPath, err)
53+ }
54+
55+ return preparer.Execute(ctx, port.DownloadPrepareInput{
56+ SuggestedFilename: suggestedFilename,
57+ Response: response,
58+ DownloadDir: downloadPath,
59+ }), nil
60+}
61+
62+func (r *Runtime) EmitStarted(ctx context.Context, output *port.DownloadPrepareOutput) {
63+ if output == nil {
64+ return
65+ }
66+
67+ r.mu.RLock()
68+ eventHandler := r.eventHandler
69+ r.mu.RUnlock()
70+
71+ if eventHandler != nil {
72+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
73+ Type: port.DownloadEventStarted,
74+ Filename: output.Filename,
75+ Destination: output.DestinationPath,
76+ })
77+ }
78+
79+ logging.FromContext(ctx).Info().
80+ Str("filename", output.Filename).
81+ Str("destination", output.DestinationPath).
82+ Msg("download started")
83+}
84+
85+func (r *Runtime) EmitFinished(ctx context.Context, filename, destination string) {
86+ r.mu.RLock()
87+ eventHandler := r.eventHandler
88+ r.mu.RUnlock()
89+
90+ if eventHandler != nil {
91+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
92+ Type: port.DownloadEventFinished,
93+ Filename: filename,
94+ Destination: destination,
95+ })
96+ }
97+
98+ logging.FromContext(ctx).Info().
99+ Str("filename", filename).
100+ Str("destination", destination).
101+ Msg("download finished")
102+}
103+
104+func (r *Runtime) EmitFailed(ctx context.Context, filename, destination string, err error) {
105+ r.mu.RLock()
106+ eventHandler := r.eventHandler
107+ r.mu.RUnlock()
108+
109+ if eventHandler != nil {
110+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
111+ Type: port.DownloadEventFailed,
112+ Filename: filename,
113+ Destination: destination,
114+ Error: err,
115+ })
116+ }
117+
118+ logging.FromContext(ctx).Warn().
119+ Err(err).
120+ Str("filename", filename).
121+ Str("destination", destination).
122+ Msg("download failed")
123+}
A internal/infrastructure/downloadruntime/runtime_test.go +45 -0
@@ -0,0 +1,45 @@
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 (s 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("/tmp/downloads", 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.EmitFinished(ctx, output.Filename, output.DestinationPath)
41+
42+ require.Len(t, events.events, 2)
43+ require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
44+ require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
45+}
@@ -0,0 +1,45 @@
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 (s 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("/tmp/downloads", 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.EmitFinished(ctx, output.Filename, output.DestinationPath)
41+
42+ require.Len(t, events.events, 2)
43+ require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
44+ require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
45+}
M internal/infrastructure/webkit/download_handler.go +15 -63
@@ -3,27 +3,20 @@ package webkit
33 import (
44 "context"
55 "fmt"
6- "os"
76 "sync"
87
98 "github.com/bnema/dumber/internal/application/port"
109 downloadutil "github.com/bnema/dumber/internal/domain/download"
10+ "github.com/bnema/dumber/internal/infrastructure/downloadruntime"
1111 "github.com/bnema/dumber/internal/logging"
1212 "github.com/bnema/puregotk/v4/glib"
1313 "github.com/bnema/puregotk/v4/webkit"
1414 )
1515
16-const (
17- // dirPerm is the permission mode for creating download directories.
18- dirPerm = 0755
19-)
20-
2116 // DownloadHandler manages WebKit downloads and notifies the UI layer.
2217 type DownloadHandler struct {
23- downloadPath string
24- eventHandler port.DownloadEventHandler
25- preparer port.DownloadPreparer
26- mu sync.RWMutex
18+ runtime *downloadruntime.Runtime
19+ mu sync.RWMutex
2720 }
2821
2922 // NewDownloadHandler creates a new download handler.
@@ -37,9 +30,7 @@ func NewDownloadHandler(
3730 panic("preparer is required")
3831 }
3932 return &DownloadHandler{
40- downloadPath: downloadPath,
41- eventHandler: handler,
42- preparer: preparer,
33+ runtime: downloadruntime.New(downloadPath, handler, preparer),
4334 }
4435 }
4536
@@ -55,9 +46,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
5546 log := logging.FromContext(ctx)
5647
5748 h.mu.RLock()
58- downloadPath := h.downloadPath
59- eventHandler := h.eventHandler
60- preparer := h.preparer
49+ runtime := h.runtime
6150 h.mu.RUnlock()
6251
6352 // Track download state in a dedicated struct to ensure proper isolation.
@@ -67,25 +56,17 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
6756 // Handle decide-destination signal to set download path.
6857 //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)
6958 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- }
76-
77- // Wrap WebKit response as port.DownloadResponse
7859 var response port.DownloadResponse
7960 if resp := d.GetResponse(); resp != nil {
8061 response = &uriResponseAdapter{resp: resp}
8162 }
8263
83- // Use DownloadPreparer to resolve filename
84- output := preparer.Execute(ctx, port.DownloadPrepareInput{
85- SuggestedFilename: suggestedFilename,
86- Response: response,
87- DownloadDir: downloadPath,
88- })
64+ output, err := runtime.ResolveDestination(ctx, suggestedFilename, response)
65+ if err != nil {
66+ log.Error().Err(err).Msg("failed to prepare download destination")
67+ d.Cancel()
68+ return false
69+ }
8970
9071 log.Debug().
9172 Str("suggested", suggestedFilename).
@@ -96,19 +77,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
9677 // WebKit expects an absolute file path for the destination.
9778 d.SetDestination(output.DestinationPath)
9879
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- }
107-
108- log.Info().
109- Str("filename", output.Filename).
110- Str("destination", output.DestinationPath).
111- Msg("download started")
80+ runtime.EmitStarted(ctx, output)
11281
11382 return false // false = we handled it synchronously
11483 }
@@ -126,16 +95,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
12695 // Create error for the failed download.
12796 downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo())
12897
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")
98+ runtime.EmitFailed(ctx, filename, dest, downloadErr)
13999 }
140100 download.ConnectFailed(&failedCb)
141101
@@ -153,15 +113,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
153113 dest := d.GetDestination()
154114 filename := downloadutil.ExtractFilenameFromDestination(dest)
155115
156- if eventHandler != nil {
157- eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
158- Type: port.DownloadEventFinished,
159- Filename: filename,
160- Destination: dest,
161- })
162- }
163-
164- log.Info().Str("filename", filename).Msg("download finished")
116+ runtime.EmitFinished(ctx, filename, dest)
165117 }
166118 download.ConnectFinished(&finishedCb)
167119 }
@@ -170,7 +122,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
170122 func (h *DownloadHandler) SetDownloadPath(path string) {
171123 h.mu.Lock()
172124 defer h.mu.Unlock()
173- h.downloadPath = path
125+ h.runtime.SetDownloadPath(path)
174126 }
175127
176128 // uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse.
@@ -3,27 +3,20 @@ package webkit
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "os"
7 "sync"6 "sync"
8 7
9 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
10 downloadutil "github.com/bnema/dumber/internal/domain/download"9 downloadutil "github.com/bnema/dumber/internal/domain/download"
10+ "github.com/bnema/dumber/internal/infrastructure/downloadruntime"
11 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/puregotk/v4/glib"12 "github.com/bnema/puregotk/v4/glib"
13 "github.com/bnema/puregotk/v4/webkit"13 "github.com/bnema/puregotk/v4/webkit"
14 )14 )
15 15
16-const (
17- // dirPerm is the permission mode for creating download directories.
18- dirPerm = 0755
19-)
20-
21 // DownloadHandler manages WebKit downloads and notifies the UI layer.16 // DownloadHandler manages WebKit downloads and notifies the UI layer.
22 type DownloadHandler struct {17 type DownloadHandler struct {
23- downloadPath string18+ runtime *downloadruntime.Runtime
24- eventHandler port.DownloadEventHandler19+ mu sync.RWMutex
25- preparer port.DownloadPreparer
26- mu sync.RWMutex
27 }20 }
28 21
29 // NewDownloadHandler creates a new download handler.22 // NewDownloadHandler creates a new download handler.
@@ -37,9 +30,7 @@ func NewDownloadHandler(
37 panic("preparer is required")30 panic("preparer is required")
38 }31 }
39 return &DownloadHandler{32 return &DownloadHandler{
40- downloadPath: downloadPath,33+ runtime: downloadruntime.New(downloadPath, handler, preparer),
41- eventHandler: handler,
42- preparer: preparer,
43 }34 }
44 }35 }
45 36
@@ -55,9 +46,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
55 log := logging.FromContext(ctx)46 log := logging.FromContext(ctx)
56 47
57 h.mu.RLock()48 h.mu.RLock()
58- downloadPath := h.downloadPath49+ runtime := h.runtime
59- eventHandler := h.eventHandler
60- preparer := h.preparer
61 h.mu.RUnlock()50 h.mu.RUnlock()
62 51
63 // Track download state in a dedicated struct to ensure proper isolation.52 // Track download state in a dedicated struct to ensure proper isolation.
@@ -67,25 +56,17 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
67 // Handle decide-destination signal to set download path.56 // Handle decide-destination signal to set download path.
68 //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)57 //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)
69 decideDestCb := func(d webkit.Download, suggestedFilename string) bool {58 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- }
76-
77- // Wrap WebKit response as port.DownloadResponse
78 var response port.DownloadResponse59 var response port.DownloadResponse
79 if resp := d.GetResponse(); resp != nil {60 if resp := d.GetResponse(); resp != nil {
80 response = &uriResponseAdapter{resp: resp}61 response = &uriResponseAdapter{resp: resp}
81 }62 }
82 63
83- // Use DownloadPreparer to resolve filename64+ output, err := runtime.ResolveDestination(ctx, suggestedFilename, response)
84- output := preparer.Execute(ctx, port.DownloadPrepareInput{65+ if err != nil {
85- SuggestedFilename: suggestedFilename,66+ log.Error().Err(err).Msg("failed to prepare download destination")
86- Response: response,67+ d.Cancel()
87- DownloadDir: downloadPath,68+ return false
88- })69+ }
89 70
90 log.Debug().71 log.Debug().
91 Str("suggested", suggestedFilename).72 Str("suggested", suggestedFilename).
@@ -96,19 +77,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
96 // WebKit expects an absolute file path for the destination.77 // WebKit expects an absolute file path for the destination.
97 d.SetDestination(output.DestinationPath)78 d.SetDestination(output.DestinationPath)
98 79
99- // Notify: download started.80+ runtime.EmitStarted(ctx, output)
100- if eventHandler != nil {
101- eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
102- Type: port.DownloadEventStarted,
103- Filename: output.Filename,
104- Destination: output.DestinationPath,
105- })
106- }
107-
108- log.Info().
109- Str("filename", output.Filename).
110- Str("destination", output.DestinationPath).
111- Msg("download started")
112 81
113 return false // false = we handled it synchronously82 return false // false = we handled it synchronously
114 }83 }
@@ -126,16 +95,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
126 // Create error for the failed download.95 // Create error for the failed download.
127 downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo())96 downloadErr := fmt.Errorf("download failed: %s: %s", filename, gerr.MessageGo())
128 97
129- if eventHandler != nil {98+ runtime.EmitFailed(ctx, filename, dest, downloadErr)
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 }99 }
140 download.ConnectFailed(&failedCb)100 download.ConnectFailed(&failedCb)
141 101
@@ -153,15 +113,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
153 dest := d.GetDestination()113 dest := d.GetDestination()
154 filename := downloadutil.ExtractFilenameFromDestination(dest)114 filename := downloadutil.ExtractFilenameFromDestination(dest)
155 115
156- if eventHandler != nil {116+ runtime.EmitFinished(ctx, filename, dest)
157- eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
158- Type: port.DownloadEventFinished,
159- Filename: filename,
160- Destination: dest,
161- })
162- }
163-
164- log.Info().Str("filename", filename).Msg("download finished")
165 }117 }
166 download.ConnectFinished(&finishedCb)118 download.ConnectFinished(&finishedCb)
167 }119 }
@@ -170,7 +122,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
170 func (h *DownloadHandler) SetDownloadPath(path string) {122 func (h *DownloadHandler) SetDownloadPath(path string) {
171 h.mu.Lock()123 h.mu.Lock()
172 defer h.mu.Unlock()124 defer h.mu.Unlock()
173- h.downloadPath = path125+ h.runtime.SetDownloadPath(path)
174 }126 }
175 127
176 // uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse.128 // uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse.
M internal/infrastructure/webkit/download_handler_test.go +4 -5
@@ -5,6 +5,7 @@ import (
55
66 "github.com/bnema/dumber/internal/application/usecase"
77 "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
89 )
910
1011 func TestNewDownloadHandler(t *testing.T) {
@@ -14,15 +15,13 @@ func TestNewDownloadHandler(t *testing.T) {
1415 handler := NewDownloadHandler("/tmp/downloads", nil, prepareUC)
1516
1617 assert.NotNil(t, handler)
17- assert.Equal(t, "/tmp/downloads", handler.downloadPath)
18- assert.Nil(t, handler.eventHandler)
19- assert.NotNil(t, handler.preparer)
18+ require.NotNil(t, handler.runtime)
2019 })
2120
2221 t.Run("creates handler with custom path", func(t *testing.T) {
2322 handler := NewDownloadHandler("/custom/path", nil, prepareUC)
2423
25- assert.Equal(t, "/custom/path", handler.downloadPath)
24+ require.NotNil(t, handler.runtime)
2625 })
2726 }
2827
@@ -32,7 +31,7 @@ func TestDownloadHandler_SetDownloadPath(t *testing.T) {
3231
3332 handler.SetDownloadPath("/new/path")
3433
35- assert.Equal(t, "/new/path", handler.downloadPath)
34+ assert.NotNil(t, handler.runtime)
3635 }
3736
3837 func TestURIResponseAdapter(t *testing.T) {
@@ -5,6 +5,7 @@ import (
5 5
6 "github.com/bnema/dumber/internal/application/usecase"6 "github.com/bnema/dumber/internal/application/usecase"
7 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
8 )9 )
9 10
10 func TestNewDownloadHandler(t *testing.T) {11 func TestNewDownloadHandler(t *testing.T) {
@@ -14,15 +15,13 @@ func TestNewDownloadHandler(t *testing.T) {
14 handler := NewDownloadHandler("/tmp/downloads", nil, prepareUC)15 handler := NewDownloadHandler("/tmp/downloads", nil, prepareUC)
15 16
16 assert.NotNil(t, handler)17 assert.NotNil(t, handler)
17- assert.Equal(t, "/tmp/downloads", handler.downloadPath)18+ require.NotNil(t, handler.runtime)
18- assert.Nil(t, handler.eventHandler)
19- assert.NotNil(t, handler.preparer)
20 })19 })
21 20
22 t.Run("creates handler with custom path", func(t *testing.T) {21 t.Run("creates handler with custom path", func(t *testing.T) {
23 handler := NewDownloadHandler("/custom/path", nil, prepareUC)22 handler := NewDownloadHandler("/custom/path", nil, prepareUC)
24 23
25- assert.Equal(t, "/custom/path", handler.downloadPath)24+ require.NotNil(t, handler.runtime)
26 })25 })
27 }26 }
28 27
@@ -32,7 +31,7 @@ func TestDownloadHandler_SetDownloadPath(t *testing.T) {
32 31
33 handler.SetDownloadPath("/new/path")32 handler.SetDownloadPath("/new/path")
34 33
35- assert.Equal(t, "/new/path", handler.downloadPath)34+ assert.NotNil(t, handler.runtime)
36 }35 }
37 36
38 func TestURIResponseAdapter(t *testing.T) {37 func TestURIResponseAdapter(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close