detached d9269b5

feat(webkit): implement download handling with XDG support

commit
d9269b5a91423adce79e29bc056567b7672bb56b
author
brice · · 2026-01-07 09:55 UTC
parent
51ce927
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +168 -3
M internal/infrastructure/webkit/context.go +28 -3
@@ -26,9 +26,10 @@ type WebKitContext struct {
2626 dataDir string
2727 cacheDir string
2828
29- logger zerolog.Logger
30- mu sync.RWMutex
31- initialized bool
29+ logger zerolog.Logger
30+ mu sync.RWMutex
31+ initialized bool
32+ downloadHandler *DownloadHandler
3233 }
3334
3435 // NewWebKitContext creates and initializes a WebKitContext with a persistent NetworkSession.
@@ -236,3 +237,27 @@ func (c *WebKitContext) Close() error {
236237 c.logger.Debug().Msg("webkit context closed")
237238 return nil
238239 }
240+
241+// SetDownloadHandler configures download handling for this context.
242+// The handler will be notified of all download events from the NetworkSession.
243+func (c *WebKitContext) SetDownloadHandler(ctx context.Context, handler *DownloadHandler) {
244+ c.mu.Lock()
245+ c.downloadHandler = handler
246+ session := c.networkSession
247+ c.mu.Unlock()
248+
249+ if session == nil || handler == nil {
250+ return
251+ }
252+
253+ // Connect download-started signal on NetworkSession.
254+ downloadStartedCb := func(_ webkit.NetworkSession, downloadPtr uintptr) {
255+ download := webkit.DownloadNewFromInternalPtr(downloadPtr)
256+ if download != nil {
257+ handler.HandleDownload(ctx, download)
258+ }
259+ }
260+ session.ConnectDownloadStarted(&downloadStartedCb)
261+
262+ c.logger.Debug().Msg("download handler connected to network session")
263+}
@@ -26,9 +26,10 @@ type WebKitContext struct {
26 dataDir string26 dataDir string
27 cacheDir string27 cacheDir string
28 28
29- logger zerolog.Logger29+ logger zerolog.Logger
30- mu sync.RWMutex30+ mu sync.RWMutex
31- initialized bool31+ initialized bool
32+ downloadHandler *DownloadHandler
32 }33 }
33 34
34 // NewWebKitContext creates and initializes a WebKitContext with a persistent NetworkSession.35 // NewWebKitContext creates and initializes a WebKitContext with a persistent NetworkSession.
@@ -236,3 +237,27 @@ func (c *WebKitContext) Close() error {
236 c.logger.Debug().Msg("webkit context closed")237 c.logger.Debug().Msg("webkit context closed")
237 return nil238 return nil
238 }239 }
240+
241+// SetDownloadHandler configures download handling for this context.
242+// The handler will be notified of all download events from the NetworkSession.
243+func (c *WebKitContext) SetDownloadHandler(ctx context.Context, handler *DownloadHandler) {
244+ c.mu.Lock()
245+ c.downloadHandler = handler
246+ session := c.networkSession
247+ c.mu.Unlock()
248+
249+ if session == nil || handler == nil {
250+ return
251+ }
252+
253+ // Connect download-started signal on NetworkSession.
254+ downloadStartedCb := func(_ webkit.NetworkSession, downloadPtr uintptr) {
255+ download := webkit.DownloadNewFromInternalPtr(downloadPtr)
256+ if download != nil {
257+ handler.HandleDownload(ctx, download)
258+ }
259+ }
260+ session.ConnectDownloadStarted(&downloadStartedCb)
261+
262+ c.logger.Debug().Msg("download handler connected to network session")
263+}
A internal/infrastructure/webkit/download_handler.go +126 -0
@@ -0,0 +1,126 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "strings"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/puregotk-webkit/webkit"
12+)
13+
14+// DownloadHandler manages WebKit downloads and notifies the UI layer.
15+type DownloadHandler struct {
16+ downloadPath string
17+ eventHandler port.DownloadEventHandler
18+ mu sync.RWMutex
19+}
20+
21+// NewDownloadHandler creates a new download handler.
22+func NewDownloadHandler(downloadPath string, handler port.DownloadEventHandler) *DownloadHandler {
23+ return &DownloadHandler{
24+ downloadPath: downloadPath,
25+ eventHandler: handler,
26+ }
27+}
28+
29+// HandleDownload sets up signal handlers for a new download.
30+func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {
31+ log := logging.FromContext(ctx)
32+
33+ h.mu.RLock()
34+ downloadPath := h.downloadPath
35+ eventHandler := h.eventHandler
36+ h.mu.RUnlock()
37+
38+ // Track if download failed (to avoid duplicate finished notifications).
39+ var downloadFailed bool
40+ var failedMu sync.Mutex
41+
42+ // Handle decide-destination signal to set download path.
43+ decideDestCb := func(d webkit.Download, suggestedFilename string) bool {
44+ destPath := filepath.Join(downloadPath, suggestedFilename)
45+ // WebKit expects a file:// URI for the destination.
46+ d.SetDestination("file://" + destPath)
47+
48+ // Notify: download started.
49+ if eventHandler != nil {
50+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
51+ Type: port.DownloadEventStarted,
52+ Filename: suggestedFilename,
53+ Destination: destPath,
54+ })
55+ }
56+
57+ log.Info().
58+ Str("filename", suggestedFilename).
59+ Str("destination", destPath).
60+ Msg("download started")
61+
62+ return false // false = we handled it synchronously
63+ }
64+ download.ConnectDecideDestination(&decideDestCb)
65+
66+ // Handle failed signal.
67+ failedCb := func(d webkit.Download, errPtr uintptr) {
68+ failedMu.Lock()
69+ downloadFailed = true
70+ failedMu.Unlock()
71+
72+ dest := d.GetDestination()
73+ filename := extractFilename(dest)
74+
75+ if eventHandler != nil {
76+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
77+ Type: port.DownloadEventFailed,
78+ Filename: filename,
79+ Destination: dest,
80+ })
81+ }
82+
83+ log.Warn().Str("filename", filename).Msg("download failed")
84+ }
85+ download.ConnectFailed(&failedCb)
86+
87+ // Handle finished signal.
88+ finishedCb := func(d webkit.Download) {
89+ failedMu.Lock()
90+ failed := downloadFailed
91+ failedMu.Unlock()
92+
93+ // Don't send finished event if we already sent a failed event.
94+ if failed {
95+ return
96+ }
97+
98+ dest := d.GetDestination()
99+ filename := extractFilename(dest)
100+
101+ if eventHandler != nil {
102+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
103+ Type: port.DownloadEventFinished,
104+ Filename: filename,
105+ Destination: dest,
106+ })
107+ }
108+
109+ log.Info().Str("filename", filename).Msg("download finished")
110+ }
111+ download.ConnectFinished(&finishedCb)
112+}
113+
114+// SetDownloadPath updates the download directory.
115+func (h *DownloadHandler) SetDownloadPath(path string) {
116+ h.mu.Lock()
117+ defer h.mu.Unlock()
118+ h.downloadPath = path
119+}
120+
121+// extractFilename extracts the filename from a file:// URI or path.
122+func extractFilename(dest string) string {
123+ // Remove file:// prefix if present.
124+ path := strings.TrimPrefix(dest, "file://")
125+ return filepath.Base(path)
126+}
@@ -0,0 +1,126 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "strings"
7+ "sync"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/logging"
11+ "github.com/bnema/puregotk-webkit/webkit"
12+)
13+
14+// DownloadHandler manages WebKit downloads and notifies the UI layer.
15+type DownloadHandler struct {
16+ downloadPath string
17+ eventHandler port.DownloadEventHandler
18+ mu sync.RWMutex
19+}
20+
21+// NewDownloadHandler creates a new download handler.
22+func NewDownloadHandler(downloadPath string, handler port.DownloadEventHandler) *DownloadHandler {
23+ return &DownloadHandler{
24+ downloadPath: downloadPath,
25+ eventHandler: handler,
26+ }
27+}
28+
29+// HandleDownload sets up signal handlers for a new download.
30+func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {
31+ log := logging.FromContext(ctx)
32+
33+ h.mu.RLock()
34+ downloadPath := h.downloadPath
35+ eventHandler := h.eventHandler
36+ h.mu.RUnlock()
37+
38+ // Track if download failed (to avoid duplicate finished notifications).
39+ var downloadFailed bool
40+ var failedMu sync.Mutex
41+
42+ // Handle decide-destination signal to set download path.
43+ decideDestCb := func(d webkit.Download, suggestedFilename string) bool {
44+ destPath := filepath.Join(downloadPath, suggestedFilename)
45+ // WebKit expects a file:// URI for the destination.
46+ d.SetDestination("file://" + destPath)
47+
48+ // Notify: download started.
49+ if eventHandler != nil {
50+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
51+ Type: port.DownloadEventStarted,
52+ Filename: suggestedFilename,
53+ Destination: destPath,
54+ })
55+ }
56+
57+ log.Info().
58+ Str("filename", suggestedFilename).
59+ Str("destination", destPath).
60+ Msg("download started")
61+
62+ return false // false = we handled it synchronously
63+ }
64+ download.ConnectDecideDestination(&decideDestCb)
65+
66+ // Handle failed signal.
67+ failedCb := func(d webkit.Download, errPtr uintptr) {
68+ failedMu.Lock()
69+ downloadFailed = true
70+ failedMu.Unlock()
71+
72+ dest := d.GetDestination()
73+ filename := extractFilename(dest)
74+
75+ if eventHandler != nil {
76+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
77+ Type: port.DownloadEventFailed,
78+ Filename: filename,
79+ Destination: dest,
80+ })
81+ }
82+
83+ log.Warn().Str("filename", filename).Msg("download failed")
84+ }
85+ download.ConnectFailed(&failedCb)
86+
87+ // Handle finished signal.
88+ finishedCb := func(d webkit.Download) {
89+ failedMu.Lock()
90+ failed := downloadFailed
91+ failedMu.Unlock()
92+
93+ // Don't send finished event if we already sent a failed event.
94+ if failed {
95+ return
96+ }
97+
98+ dest := d.GetDestination()
99+ filename := extractFilename(dest)
100+
101+ if eventHandler != nil {
102+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
103+ Type: port.DownloadEventFinished,
104+ Filename: filename,
105+ Destination: dest,
106+ })
107+ }
108+
109+ log.Info().Str("filename", filename).Msg("download finished")
110+ }
111+ download.ConnectFinished(&finishedCb)
112+}
113+
114+// SetDownloadPath updates the download directory.
115+func (h *DownloadHandler) SetDownloadPath(path string) {
116+ h.mu.Lock()
117+ defer h.mu.Unlock()
118+ h.downloadPath = path
119+}
120+
121+// extractFilename extracts the filename from a file:// URI or path.
122+func extractFilename(dest string) string {
123+ // Remove file:// prefix if present.
124+ path := strings.TrimPrefix(dest, "file://")
125+ return filepath.Base(path)
126+}
M internal/infrastructure/xdg/adapter.go +14 -0
@@ -72,4 +72,18 @@ func (a *Adapter) ManDir() (string, error) {
7272 return filepath.Join(dataHome, "man", "man1"), nil
7373 }
7474
75+func (a *Adapter) DownloadDir() (string, error) {
76+ // Check XDG_DOWNLOAD_DIR first (set by xdg-user-dirs).
77+ if dir := os.Getenv("XDG_DOWNLOAD_DIR"); dir != "" {
78+ return dir, nil
79+ }
80+
81+ // Fallback to ~/Downloads.
82+ home, err := os.UserHomeDir()
83+ if err != nil {
84+ return "", err
85+ }
86+ return filepath.Join(home, "Downloads"), nil
87+}
88+
7589 var _ port.XDGPaths = (*Adapter)(nil)
@@ -72,4 +72,18 @@ func (a *Adapter) ManDir() (string, error) {
72 return filepath.Join(dataHome, "man", "man1"), nil72 return filepath.Join(dataHome, "man", "man1"), nil
73 }73 }
74 74
75+func (a *Adapter) DownloadDir() (string, error) {
76+ // Check XDG_DOWNLOAD_DIR first (set by xdg-user-dirs).
77+ if dir := os.Getenv("XDG_DOWNLOAD_DIR"); dir != "" {
78+ return dir, nil
79+ }
80+
81+ // Fallback to ~/Downloads.
82+ home, err := os.UserHomeDir()
83+ if err != nil {
84+ return "", err
85+ }
86+ return filepath.Join(home, "Downloads"), nil
87+}
88+
75 var _ port.XDGPaths = (*Adapter)(nil)89 var _ port.XDGPaths = (*Adapter)(nil)

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