v0.25.0 2e6c485

download_handler.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8	"strings"
9	"sync"
10
11	"github.com/bnema/dumber/internal/application/port"
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/bnema/puregotk-webkit/webkit"
14)
15
16const (
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.
22type DownloadHandler struct {
23	downloadPath string
24	eventHandler port.DownloadEventHandler
25	mu           sync.RWMutex
26}
27
28// NewDownloadHandler creates a new download handler.
29func NewDownloadHandler(downloadPath string, handler port.DownloadEventHandler) *DownloadHandler {
30	return &DownloadHandler{
31		downloadPath: downloadPath,
32		eventHandler: handler,
33	}
34}
35
36// downloadState tracks the state of a single download to prevent duplicate notifications.
37// This is allocated per-download to ensure proper isolation between concurrent downloads.
38type downloadState struct {
39	failed bool
40	mu     sync.Mutex
41}
42
43// HandleDownload sets up signal handlers for a new download.
44func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {
45	log := logging.FromContext(ctx)
46
47	h.mu.RLock()
48	downloadPath := h.downloadPath
49	eventHandler := h.eventHandler
50	h.mu.RUnlock()
51
52	// Track download state in a dedicated struct to ensure proper isolation.
53	// Each download gets its own state to prevent interference between concurrent downloads.
54	state := &downloadState{}
55
56	// Handle decide-destination signal to set download path.
57	//nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)
58	decideDestCb := func(d webkit.Download, suggestedFilename string) bool {
59		// Ensure download directory exists.
60		if err := os.MkdirAll(downloadPath, dirPerm); err != nil {
61			log.Error().Err(err).Str("path", downloadPath).Msg("failed to create download directory")
62			d.Cancel()
63			return false
64		}
65
66		// Sanitize filename to prevent path traversal attacks.
67		safeName := sanitizeFilename(suggestedFilename)
68		destPath := filepath.Join(downloadPath, safeName)
69
70		log.Debug().
71			Str("suggested", suggestedFilename).
72			Str("sanitized", safeName).
73			Str("destPath", destPath).
74			Msg("setting download destination")
75
76		// WebKit expects an absolute file path for the destination.
77		d.SetDestination(destPath)
78
79		// Notify: download started.
80		if eventHandler != nil {
81			eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
82				Type:        port.DownloadEventStarted,
83				Filename:    safeName,
84				Destination: destPath,
85			})
86		}
87
88		log.Info().
89			Str("filename", safeName).
90			Str("destination", destPath).
91			Msg("download started")
92
93		return false // false = we handled it synchronously
94	}
95	download.ConnectDecideDestination(&decideDestCb)
96
97	// Handle failed signal.
98	failedCb := func(d webkit.Download, _ uintptr) {
99		state.mu.Lock()
100		state.failed = true
101		state.mu.Unlock()
102
103		dest := d.GetDestination()
104		filename := extractFilename(dest)
105
106		// Create error for the failed download.
107		downloadErr := fmt.Errorf("download failed: %s", filename)
108
109		if eventHandler != nil {
110			eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
111				Type:        port.DownloadEventFailed,
112				Filename:    filename,
113				Destination: dest,
114				Error:       downloadErr,
115			})
116		}
117
118		log.Warn().Str("filename", filename).Msg("download failed")
119	}
120	download.ConnectFailed(&failedCb)
121
122	// Handle finished signal.
123	finishedCb := func(d webkit.Download) {
124		state.mu.Lock()
125		failed := state.failed
126		state.mu.Unlock()
127
128		// Don't send finished event if we already sent a failed event.
129		if failed {
130			return
131		}
132
133		dest := d.GetDestination()
134		filename := extractFilename(dest)
135
136		if eventHandler != nil {
137			eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
138				Type:        port.DownloadEventFinished,
139				Filename:    filename,
140				Destination: dest,
141			})
142		}
143
144		log.Info().Str("filename", filename).Msg("download finished")
145	}
146	download.ConnectFinished(&finishedCb)
147}
148
149// SetDownloadPath updates the download directory.
150func (h *DownloadHandler) SetDownloadPath(path string) {
151	h.mu.Lock()
152	defer h.mu.Unlock()
153	h.downloadPath = path
154}
155
156// extractFilename extracts the filename from a file:// URI or path.
157func extractFilename(dest string) string {
158	// Remove file:// prefix if present.
159	path := strings.TrimPrefix(dest, "file://")
160	base := filepath.Base(path)
161
162	// Handle edge cases consistently with sanitizeFilename.
163	if base == "." || base == "" {
164		return "download"
165	}
166	return base
167}
168
169// sanitizeFilename sanitizes a suggested filename to prevent path traversal attacks.
170// It extracts only the base name and handles edge cases like "." or "..".
171func sanitizeFilename(name string) string {
172	// Normalize Windows-style separators to forward slashes.
173	// filepath.Base only handles the OS-native separator, so on Linux
174	// backslashes would not be treated as path separators.
175	name = strings.ReplaceAll(name, "\\", "/")
176
177	// Get only the base name (removes any directory components).
178	clean := filepath.Base(name)
179
180	// If Base returns "." or ".." (edge cases), use fallback.
181	if clean == "." || clean == ".." || clean == "" {
182		return "download"
183	}
184
185	return clean
186}

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