detached e9e5ddc

Merge pull request #112 from bnema/feat/download-handling

feat: add file download handling with toaster notifications
commit
e9e5ddcc79980e7a24fefea33d2a4f1f2bce1939
author
bnema · · 2026-01-07 18:28 UTC
committer
GitHub · · 2026-01-07 18:28 UTC
merge of
47ccc89 + 4a8a837 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
17 files · +585 -3
M CHANGELOG.md +1 -0
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **File download handling**: Downloads from websites now save to the user's download directory with toast notifications for start/complete/failed states. Configurable via `downloads.path` (defaults to `$XDG_DOWNLOAD_DIR` or `~/Downloads`).
89 - **Print shortcut**: `Ctrl+Shift+P` opens the print dialog for the current page. Fixes #99.
910
1011 ### Changed
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **File download handling**: Downloads from websites now save to the user's download directory with toast notifications for start/complete/failed states. Configurable via `downloads.path` (defaults to `$XDG_DOWNLOAD_DIR` or `~/Downloads`).
8 - **Print shortcut**: `Ctrl+Shift+P` opens the print dialog for the current page. Fixes #99.9 - **Print shortcut**: `Ctrl+Shift+P` opens the print dialog for the current page. Fixes #99.
9 10
10 ### Changed11 ### Changed
M cmd/dumber/main.go +2 -0
@@ -25,6 +25,7 @@ import (
2525 "github.com/bnema/dumber/internal/infrastructure/idle"
2626 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
2727 "github.com/bnema/dumber/internal/infrastructure/updater"
28+ "github.com/bnema/dumber/internal/infrastructure/xdg"
2829 "github.com/bnema/dumber/internal/logging"
2930 "github.com/bnema/dumber/internal/ui"
3031 "github.com/bnema/dumber/internal/ui/theme"
@@ -428,6 +429,7 @@ func buildUIDependencies(
428429 Theme: themeManager,
429430 ColorResolver: colorResolver,
430431 AdwaitaDetector: adwaitaDetector,
432+ XDG: xdg.New(),
431433 WebContext: stack.Context,
432434 Pool: stack.Pool,
433435 Settings: stack.Settings,
@@ -25,6 +25,7 @@ import (
25 "github.com/bnema/dumber/internal/infrastructure/idle"25 "github.com/bnema/dumber/internal/infrastructure/idle"
26 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"26 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
27 "github.com/bnema/dumber/internal/infrastructure/updater"27 "github.com/bnema/dumber/internal/infrastructure/updater"
28+ "github.com/bnema/dumber/internal/infrastructure/xdg"
28 "github.com/bnema/dumber/internal/logging"29 "github.com/bnema/dumber/internal/logging"
29 "github.com/bnema/dumber/internal/ui"30 "github.com/bnema/dumber/internal/ui"
30 "github.com/bnema/dumber/internal/ui/theme"31 "github.com/bnema/dumber/internal/ui/theme"
@@ -428,6 +429,7 @@ func buildUIDependencies(
428 Theme: themeManager,429 Theme: themeManager,
429 ColorResolver: colorResolver,430 ColorResolver: colorResolver,
430 AdwaitaDetector: adwaitaDetector,431 AdwaitaDetector: adwaitaDetector,
432+ XDG: xdg.New(),
431 WebContext: stack.Context,433 WebContext: stack.Context,
432 Pool: stack.Pool,434 Pool: stack.Pool,
433 Settings: stack.Settings,435 Settings: stack.Settings,
M docs/CONFIG.md +17 -0
@@ -597,6 +597,23 @@ webview_pool_prewarm_count = 6
597597
598598 > **Important:** Individual tuning fields are ignored unless `profile = "custom"`. Setting individual fields with any other profile will produce a validation warning.
599599
600+## Downloads
601+
602+| Key | Type | Default | Description |
603+|-----|------|---------|-------------|
604+| `downloads.path` | string | `""` | Download directory path (empty = `$XDG_DOWNLOAD_DIR` or `~/Downloads`) |
605+
606+Downloads are saved to the configured directory with toast notifications for download started, completed, and failed events.
607+
608+**Example:**
609+```toml
610+[downloads]
611+path = "" # Use system default ($XDG_DOWNLOAD_DIR or ~/Downloads)
612+
613+# Or specify a custom directory:
614+# path = "/home/user/my-downloads"
615+```
616+
600617 ## Environment Variables
601618
602619 All config values can be overridden via environment variables with the prefix `DUMBER_`:
@@ -597,6 +597,23 @@ webview_pool_prewarm_count = 6
597 597
598 > **Important:** Individual tuning fields are ignored unless `profile = "custom"`. Setting individual fields with any other profile will produce a validation warning.598 > **Important:** Individual tuning fields are ignored unless `profile = "custom"`. Setting individual fields with any other profile will produce a validation warning.
599 599
600+## Downloads
601+
602+| Key | Type | Default | Description |
603+|-----|------|---------|-------------|
604+| `downloads.path` | string | `""` | Download directory path (empty = `$XDG_DOWNLOAD_DIR` or `~/Downloads`) |
605+
606+Downloads are saved to the configured directory with toast notifications for download started, completed, and failed events.
607+
608+**Example:**
609+```toml
610+[downloads]
611+path = "" # Use system default ($XDG_DOWNLOAD_DIR or ~/Downloads)
612+
613+# Or specify a custom directory:
614+# path = "/home/user/my-downloads"
615+```
616+
600 ## Environment Variables617 ## Environment Variables
601 618
602 All config values can be overridden via environment variables with the prefix `DUMBER_`:619 All config values can be overridden via environment variables with the prefix `DUMBER_`:
A internal/application/port/download.go +28 -0
@@ -0,0 +1,28 @@
1+package port
2+
3+import "context"
4+
5+// DownloadEventType represents the type of download event.
6+type DownloadEventType int
7+
8+const (
9+ // DownloadEventStarted indicates a download has begun.
10+ DownloadEventStarted DownloadEventType = iota
11+ // DownloadEventFinished indicates a download completed successfully.
12+ DownloadEventFinished
13+ // DownloadEventFailed indicates a download failed.
14+ DownloadEventFailed
15+)
16+
17+// DownloadEvent contains information about a download event.
18+type DownloadEvent struct {
19+ Type DownloadEventType
20+ Filename string
21+ Destination string
22+ Error error // Set when Type is DownloadEventFailed
23+}
24+
25+// DownloadEventHandler receives download event notifications.
26+type DownloadEventHandler interface {
27+ OnDownloadEvent(ctx context.Context, event DownloadEvent)
28+}
@@ -0,0 +1,28 @@
1+package port
2+
3+import "context"
4+
5+// DownloadEventType represents the type of download event.
6+type DownloadEventType int
7+
8+const (
9+ // DownloadEventStarted indicates a download has begun.
10+ DownloadEventStarted DownloadEventType = iota
11+ // DownloadEventFinished indicates a download completed successfully.
12+ DownloadEventFinished
13+ // DownloadEventFailed indicates a download failed.
14+ DownloadEventFailed
15+)
16+
17+// DownloadEvent contains information about a download event.
18+type DownloadEvent struct {
19+ Type DownloadEventType
20+ Filename string
21+ Destination string
22+ Error error // Set when Type is DownloadEventFailed
23+}
24+
25+// DownloadEventHandler receives download event notifications.
26+type DownloadEventHandler interface {
27+ OnDownloadEvent(ctx context.Context, event DownloadEvent)
28+}
M internal/application/port/xdg.go +4 -0
@@ -14,4 +14,8 @@ type XDGPaths interface {
1414 // ManDir returns the user man page directory (man1 section).
1515 // Typically $XDG_DATA_HOME/man/man1 or ~/.local/share/man/man1.
1616 ManDir() (string, error)
17+
18+ // DownloadDir returns the user's download directory.
19+ // Typically $XDG_DOWNLOAD_DIR or ~/Downloads.
20+ DownloadDir() (string, error)
1721 }
@@ -14,4 +14,8 @@ type XDGPaths interface {
14 // ManDir returns the user man page directory (man1 section).14 // ManDir returns the user man page directory (man1 section).
15 // Typically $XDG_DATA_HOME/man/man1 or ~/.local/share/man/man1.15 // Typically $XDG_DATA_HOME/man/man1 or ~/.local/share/man/man1.
16 ManDir() (string, error)16 ManDir() (string, error)
17+
18+ // DownloadDir returns the user's download directory.
19+ // Typically $XDG_DOWNLOAD_DIR or ~/Downloads.
20+ DownloadDir() (string, error)
17 }21 }
M internal/cli/styles/config_schema.go +2 -0
@@ -47,9 +47,11 @@ func (r *ConfigSchemaRenderer) Render(keys []entity.ConfigKeyInfo) string {
4747 "Session",
4848 "Omnibox",
4949 "Content Filtering",
50+ "Clipboard",
5051 "Rendering",
5152 "Media",
5253 "Update",
54+ "Downloads",
5355 "Debug",
5456 "Performance",
5557 "Runtime",
@@ -47,9 +47,11 @@ func (r *ConfigSchemaRenderer) Render(keys []entity.ConfigKeyInfo) string {
47 "Session",47 "Session",
48 "Omnibox",48 "Omnibox",
49 "Content Filtering",49 "Content Filtering",
50+ "Clipboard",
50 "Rendering",51 "Rendering",
51 "Media",52 "Media",
52 "Update",53 "Update",
54+ "Downloads",
53 "Debug",55 "Debug",
54 "Performance",56 "Performance",
55 "Runtime",57 "Runtime",
M internal/infrastructure/config/defaults.go +3 -0
@@ -311,6 +311,9 @@ func DefaultConfig() *Config {
311311 AutoDownload: false, // Conservative: don't auto-download by default
312312 NotifyOnNewSettings: true, // Show toast when new config settings available
313313 },
314+ Downloads: DownloadsConfig{
315+ Path: "", // Empty = use XDG_DOWNLOAD_DIR or ~/Downloads
316+ },
314317 }
315318 }
316319
@@ -311,6 +311,9 @@ func DefaultConfig() *Config {
311 AutoDownload: false, // Conservative: don't auto-download by default311 AutoDownload: false, // Conservative: don't auto-download by default
312 NotifyOnNewSettings: true, // Show toast when new config settings available312 NotifyOnNewSettings: true, // Show toast when new config settings available
313 },313 },
314+ Downloads: DownloadsConfig{
315+ Path: "", // Empty = use XDG_DOWNLOAD_DIR or ~/Downloads
316+ },
314 }317 }
315 }318 }
316 319
M internal/infrastructure/config/loader.go +5 -0
@@ -365,6 +365,7 @@ func (m *Manager) setDefaults() {
365365 m.setSessionDefaults(defaults)
366366 m.setUpdateDefaults(defaults)
367367 m.setPerformanceDefaults(defaults)
368+ m.setDownloadsDefaults(defaults)
368369 }
369370
370371 func (m *Manager) setHistoryDefaults(defaults *Config) {
@@ -533,6 +534,10 @@ func (m *Manager) setPerformanceDefaults(defaults *Config) {
533534 m.viper.SetDefault("performance.network_process_memory_strict_threshold", defaults.Performance.NetworkProcessMemoryStrictThreshold)
534535 }
535536
537+func (m *Manager) setDownloadsDefaults(defaults *Config) {
538+ m.viper.SetDefault("downloads.path", defaults.Downloads.Path)
539+}
540+
536541 // New returns a new default configuration instance.
537542 // This is a convenience function for getting default config without the full manager.
538543 func New() *Config {
@@ -365,6 +365,7 @@ func (m *Manager) setDefaults() {
365 m.setSessionDefaults(defaults)365 m.setSessionDefaults(defaults)
366 m.setUpdateDefaults(defaults)366 m.setUpdateDefaults(defaults)
367 m.setPerformanceDefaults(defaults)367 m.setPerformanceDefaults(defaults)
368+ m.setDownloadsDefaults(defaults)
368 }369 }
369 370
370 func (m *Manager) setHistoryDefaults(defaults *Config) {371 func (m *Manager) setHistoryDefaults(defaults *Config) {
@@ -533,6 +534,10 @@ func (m *Manager) setPerformanceDefaults(defaults *Config) {
533 m.viper.SetDefault("performance.network_process_memory_strict_threshold", defaults.Performance.NetworkProcessMemoryStrictThreshold)534 m.viper.SetDefault("performance.network_process_memory_strict_threshold", defaults.Performance.NetworkProcessMemoryStrictThreshold)
534 }535 }
535 536
537+func (m *Manager) setDownloadsDefaults(defaults *Config) {
538+ m.viper.SetDefault("downloads.path", defaults.Downloads.Path)
539+}
540+
536 // New returns a new default configuration instance.541 // New returns a new default configuration instance.
537 // This is a convenience function for getting default config without the full manager.542 // This is a convenience function for getting default config without the full manager.
538 func New() *Config {543 func New() *Config {
M internal/infrastructure/config/schema.go +9 -0
@@ -35,6 +35,8 @@ type Config struct {
3535 Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"`
3636 // Update controls automatic update checking and downloading.
3737 Update UpdateConfig `mapstructure:"update" yaml:"update" toml:"update"`
38+ // Downloads configures file download behavior.
39+ Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"`
3840 }
3941
4042 // RenderingMode selects GPU vs CPU rendering.
@@ -372,6 +374,13 @@ type UpdateConfig struct {
372374 NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`
373375 }
374376
377+// DownloadsConfig holds file download preferences.
378+type DownloadsConfig struct {
379+ // Path is the directory where downloads are saved.
380+ // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.
381+ Path string `mapstructure:"path" yaml:"path" toml:"path"`
382+}
383+
375384 // WorkspaceConfig captures layout, pane, and tab behavior preferences.
376385 type WorkspaceConfig struct {
377386 // NewPaneURL is the URL loaded when creating a new empty pane/tab.
@@ -35,6 +35,8 @@ type Config struct {
35 Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"`35 Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"`
36 // Update controls automatic update checking and downloading.36 // Update controls automatic update checking and downloading.
37 Update UpdateConfig `mapstructure:"update" yaml:"update" toml:"update"`37 Update UpdateConfig `mapstructure:"update" yaml:"update" toml:"update"`
38+ // Downloads configures file download behavior.
39+ Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"`
38 }40 }
39 41
40 // RenderingMode selects GPU vs CPU rendering.42 // RenderingMode selects GPU vs CPU rendering.
@@ -372,6 +374,13 @@ type UpdateConfig struct {
372 NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`374 NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`
373 }375 }
374 376
377+// DownloadsConfig holds file download preferences.
378+type DownloadsConfig struct {
379+ // Path is the directory where downloads are saved.
380+ // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.
381+ Path string `mapstructure:"path" yaml:"path" toml:"path"`
382+}
383+
375 // WorkspaceConfig captures layout, pane, and tab behavior preferences.384 // WorkspaceConfig captures layout, pane, and tab behavior preferences.
376 type WorkspaceConfig struct {385 type WorkspaceConfig struct {
377 // NewPaneURL is the URL loaded when creating a new empty pane/tab.386 // NewPaneURL is the URL loaded when creating a new empty pane/tab.
M internal/infrastructure/config/schema_provider.go +16 -0
@@ -25,6 +25,7 @@ const (
2525 SectionRuntime = "Runtime"
2626 SectionDatabase = "Database"
2727 SectionSearch = "Search"
28+ SectionDownloads = "Downloads"
2829 )
2930
3031 // SchemaProvider implements port.ConfigSchemaProvider.
@@ -92,6 +93,9 @@ func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo {
9293 // Database section
9394 keys = append(keys, p.getDatabaseKeys()...)
9495
96+ // Downloads section
97+ keys = append(keys, p.getDownloadsKeys(defaults)...)
98+
9599 return keys
96100 }
97101
@@ -1040,3 +1044,15 @@ func (*SchemaProvider) getDatabaseKeys() []entity.ConfigKeyInfo {
10401044 },
10411045 }
10421046 }
1047+
1048+func (*SchemaProvider) getDownloadsKeys(_ *Config) []entity.ConfigKeyInfo {
1049+ return []entity.ConfigKeyInfo{
1050+ {
1051+ Key: "downloads.path",
1052+ Type: "string",
1053+ Default: "(empty = $XDG_DOWNLOAD_DIR or ~/Downloads)",
1054+ Description: "Directory where downloads are saved",
1055+ Section: SectionDownloads,
1056+ },
1057+ }
1058+}
@@ -25,6 +25,7 @@ const (
25 SectionRuntime = "Runtime"25 SectionRuntime = "Runtime"
26 SectionDatabase = "Database"26 SectionDatabase = "Database"
27 SectionSearch = "Search"27 SectionSearch = "Search"
28+ SectionDownloads = "Downloads"
28 )29 )
29 30
30 // SchemaProvider implements port.ConfigSchemaProvider.31 // SchemaProvider implements port.ConfigSchemaProvider.
@@ -92,6 +93,9 @@ func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo {
92 // Database section93 // Database section
93 keys = append(keys, p.getDatabaseKeys()...)94 keys = append(keys, p.getDatabaseKeys()...)
94 95
96+ // Downloads section
97+ keys = append(keys, p.getDownloadsKeys(defaults)...)
98+
95 return keys99 return keys
96 }100 }
97 101
@@ -1040,3 +1044,15 @@ func (*SchemaProvider) getDatabaseKeys() []entity.ConfigKeyInfo {
1040 },1044 },
1041 }1045 }
1042 }1046 }
1047+
1048+func (*SchemaProvider) getDownloadsKeys(_ *Config) []entity.ConfigKeyInfo {
1049+ return []entity.ConfigKeyInfo{
1050+ {
1051+ Key: "downloads.path",
1052+ Type: "string",
1053+ Default: "(empty = $XDG_DOWNLOAD_DIR or ~/Downloads)",
1054+ Description: "Directory where downloads are saved",
1055+ Section: SectionDownloads,
1056+ },
1057+ }
1058+}
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 +179 -0
@@ -0,0 +1,179 @@
1+package webkit
2+
3+import (
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+
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.
22+type DownloadHandler struct {
23+ downloadPath string
24+ eventHandler port.DownloadEventHandler
25+ mu sync.RWMutex
26+}
27+
28+// NewDownloadHandler creates a new download handler.
29+func NewDownloadHandler(downloadPath string, handler port.DownloadEventHandler) *DownloadHandler {
30+ return &DownloadHandler{
31+ downloadPath: downloadPath,
32+ eventHandler: handler,
33+ }
34+}
35+
36+// HandleDownload sets up signal handlers for a new download.
37+func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {
38+ log := logging.FromContext(ctx)
39+
40+ h.mu.RLock()
41+ downloadPath := h.downloadPath
42+ eventHandler := h.eventHandler
43+ h.mu.RUnlock()
44+
45+ // Track if download failed (to avoid duplicate finished notifications).
46+ var downloadFailed bool
47+ var failedMu sync.Mutex
48+
49+ // Handle decide-destination signal to set download path.
50+ //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)
51+ decideDestCb := func(d webkit.Download, suggestedFilename string) bool {
52+ // Ensure download directory exists.
53+ if err := os.MkdirAll(downloadPath, dirPerm); err != nil {
54+ log.Error().Err(err).Str("path", downloadPath).Msg("failed to create download directory")
55+ d.Cancel()
56+ return false
57+ }
58+
59+ // Sanitize filename to prevent path traversal attacks.
60+ safeName := sanitizeFilename(suggestedFilename)
61+ destPath := filepath.Join(downloadPath, safeName)
62+
63+ log.Debug().
64+ Str("suggested", suggestedFilename).
65+ Str("sanitized", safeName).
66+ Str("destPath", destPath).
67+ Msg("setting download destination")
68+
69+ // WebKit expects an absolute file path for the destination.
70+ d.SetDestination(destPath)
71+
72+ // Notify: download started.
73+ if eventHandler != nil {
74+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
75+ Type: port.DownloadEventStarted,
76+ Filename: safeName,
77+ Destination: destPath,
78+ })
79+ }
80+
81+ log.Info().
82+ Str("filename", safeName).
83+ Str("destination", destPath).
84+ Msg("download started")
85+
86+ return false // false = we handled it synchronously
87+ }
88+ download.ConnectDecideDestination(&decideDestCb)
89+
90+ // Handle failed signal.
91+ failedCb := func(d webkit.Download, _ uintptr) {
92+ failedMu.Lock()
93+ downloadFailed = true
94+ failedMu.Unlock()
95+
96+ dest := d.GetDestination()
97+ filename := extractFilename(dest)
98+
99+ // Create error for the failed download.
100+ downloadErr := fmt.Errorf("download failed: %s", filename)
101+
102+ if eventHandler != nil {
103+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
104+ Type: port.DownloadEventFailed,
105+ Filename: filename,
106+ Destination: dest,
107+ Error: downloadErr,
108+ })
109+ }
110+
111+ log.Warn().Str("filename", filename).Msg("download failed")
112+ }
113+ download.ConnectFailed(&failedCb)
114+
115+ // Handle finished signal.
116+ finishedCb := func(d webkit.Download) {
117+ failedMu.Lock()
118+ failed := downloadFailed
119+ failedMu.Unlock()
120+
121+ // Don't send finished event if we already sent a failed event.
122+ if failed {
123+ return
124+ }
125+
126+ dest := d.GetDestination()
127+ filename := extractFilename(dest)
128+
129+ if eventHandler != nil {
130+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
131+ Type: port.DownloadEventFinished,
132+ Filename: filename,
133+ Destination: dest,
134+ })
135+ }
136+
137+ log.Info().Str("filename", filename).Msg("download finished")
138+ }
139+ download.ConnectFinished(&finishedCb)
140+}
141+
142+// SetDownloadPath updates the download directory.
143+func (h *DownloadHandler) SetDownloadPath(path string) {
144+ h.mu.Lock()
145+ defer h.mu.Unlock()
146+ h.downloadPath = path
147+}
148+
149+// extractFilename extracts the filename from a file:// URI or path.
150+func extractFilename(dest string) string {
151+ // Remove file:// prefix if present.
152+ path := strings.TrimPrefix(dest, "file://")
153+ base := filepath.Base(path)
154+
155+ // Handle edge cases consistently with sanitizeFilename.
156+ if base == "." || base == "" {
157+ return "download"
158+ }
159+ return base
160+}
161+
162+// sanitizeFilename sanitizes a suggested filename to prevent path traversal attacks.
163+// It extracts only the base name and handles edge cases like "." or "..".
164+func sanitizeFilename(name string) string {
165+ // Normalize Windows-style separators to forward slashes.
166+ // filepath.Base only handles the OS-native separator, so on Linux
167+ // backslashes would not be treated as path separators.
168+ name = strings.ReplaceAll(name, "\\", "/")
169+
170+ // Get only the base name (removes any directory components).
171+ clean := filepath.Base(name)
172+
173+ // If Base returns "." or ".." (edge cases), use fallback.
174+ if clean == "." || clean == ".." || clean == "" {
175+ return "download"
176+ }
177+
178+ return clean
179+}
@@ -0,0 +1,179 @@
1+package webkit
2+
3+import (
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+
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.
22+type DownloadHandler struct {
23+ downloadPath string
24+ eventHandler port.DownloadEventHandler
25+ mu sync.RWMutex
26+}
27+
28+// NewDownloadHandler creates a new download handler.
29+func NewDownloadHandler(downloadPath string, handler port.DownloadEventHandler) *DownloadHandler {
30+ return &DownloadHandler{
31+ downloadPath: downloadPath,
32+ eventHandler: handler,
33+ }
34+}
35+
36+// HandleDownload sets up signal handlers for a new download.
37+func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {
38+ log := logging.FromContext(ctx)
39+
40+ h.mu.RLock()
41+ downloadPath := h.downloadPath
42+ eventHandler := h.eventHandler
43+ h.mu.RUnlock()
44+
45+ // Track if download failed (to avoid duplicate finished notifications).
46+ var downloadFailed bool
47+ var failedMu sync.Mutex
48+
49+ // Handle decide-destination signal to set download path.
50+ //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)
51+ decideDestCb := func(d webkit.Download, suggestedFilename string) bool {
52+ // Ensure download directory exists.
53+ if err := os.MkdirAll(downloadPath, dirPerm); err != nil {
54+ log.Error().Err(err).Str("path", downloadPath).Msg("failed to create download directory")
55+ d.Cancel()
56+ return false
57+ }
58+
59+ // Sanitize filename to prevent path traversal attacks.
60+ safeName := sanitizeFilename(suggestedFilename)
61+ destPath := filepath.Join(downloadPath, safeName)
62+
63+ log.Debug().
64+ Str("suggested", suggestedFilename).
65+ Str("sanitized", safeName).
66+ Str("destPath", destPath).
67+ Msg("setting download destination")
68+
69+ // WebKit expects an absolute file path for the destination.
70+ d.SetDestination(destPath)
71+
72+ // Notify: download started.
73+ if eventHandler != nil {
74+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
75+ Type: port.DownloadEventStarted,
76+ Filename: safeName,
77+ Destination: destPath,
78+ })
79+ }
80+
81+ log.Info().
82+ Str("filename", safeName).
83+ Str("destination", destPath).
84+ Msg("download started")
85+
86+ return false // false = we handled it synchronously
87+ }
88+ download.ConnectDecideDestination(&decideDestCb)
89+
90+ // Handle failed signal.
91+ failedCb := func(d webkit.Download, _ uintptr) {
92+ failedMu.Lock()
93+ downloadFailed = true
94+ failedMu.Unlock()
95+
96+ dest := d.GetDestination()
97+ filename := extractFilename(dest)
98+
99+ // Create error for the failed download.
100+ downloadErr := fmt.Errorf("download failed: %s", filename)
101+
102+ if eventHandler != nil {
103+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
104+ Type: port.DownloadEventFailed,
105+ Filename: filename,
106+ Destination: dest,
107+ Error: downloadErr,
108+ })
109+ }
110+
111+ log.Warn().Str("filename", filename).Msg("download failed")
112+ }
113+ download.ConnectFailed(&failedCb)
114+
115+ // Handle finished signal.
116+ finishedCb := func(d webkit.Download) {
117+ failedMu.Lock()
118+ failed := downloadFailed
119+ failedMu.Unlock()
120+
121+ // Don't send finished event if we already sent a failed event.
122+ if failed {
123+ return
124+ }
125+
126+ dest := d.GetDestination()
127+ filename := extractFilename(dest)
128+
129+ if eventHandler != nil {
130+ eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
131+ Type: port.DownloadEventFinished,
132+ Filename: filename,
133+ Destination: dest,
134+ })
135+ }
136+
137+ log.Info().Str("filename", filename).Msg("download finished")
138+ }
139+ download.ConnectFinished(&finishedCb)
140+}
141+
142+// SetDownloadPath updates the download directory.
143+func (h *DownloadHandler) SetDownloadPath(path string) {
144+ h.mu.Lock()
145+ defer h.mu.Unlock()
146+ h.downloadPath = path
147+}
148+
149+// extractFilename extracts the filename from a file:// URI or path.
150+func extractFilename(dest string) string {
151+ // Remove file:// prefix if present.
152+ path := strings.TrimPrefix(dest, "file://")
153+ base := filepath.Base(path)
154+
155+ // Handle edge cases consistently with sanitizeFilename.
156+ if base == "." || base == "" {
157+ return "download"
158+ }
159+ return base
160+}
161+
162+// sanitizeFilename sanitizes a suggested filename to prevent path traversal attacks.
163+// It extracts only the base name and handles edge cases like "." or "..".
164+func sanitizeFilename(name string) string {
165+ // Normalize Windows-style separators to forward slashes.
166+ // filepath.Base only handles the OS-native separator, so on Linux
167+ // backslashes would not be treated as path separators.
168+ name = strings.ReplaceAll(name, "\\", "/")
169+
170+ // Get only the base name (removes any directory components).
171+ clean := filepath.Base(name)
172+
173+ // If Base returns "." or ".." (edge cases), use fallback.
174+ if clean == "." || clean == ".." || clean == "" {
175+ return "download"
176+ }
177+
178+ return clean
179+}
A internal/infrastructure/webkit/download_handler_test.go +148 -0
@@ -0,0 +1,148 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestSanitizeFilename(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ expected string
14+ }{
15+ {
16+ name: "normal filename",
17+ input: "document.pdf",
18+ expected: "document.pdf",
19+ },
20+ {
21+ name: "filename with spaces",
22+ input: "my document.pdf",
23+ expected: "my document.pdf",
24+ },
25+ {
26+ name: "path traversal with parent dirs",
27+ input: "../../../etc/passwd",
28+ expected: "passwd",
29+ },
30+ {
31+ name: "path traversal hidden file",
32+ input: "../.ssh/id_rsa",
33+ expected: "id_rsa",
34+ },
35+ {
36+ name: "nested path",
37+ input: "foo/bar/baz.txt",
38+ expected: "baz.txt",
39+ },
40+ {
41+ name: "absolute path",
42+ input: "/etc/passwd",
43+ expected: "passwd",
44+ },
45+ {
46+ name: "dot only",
47+ input: ".",
48+ expected: "download",
49+ },
50+ {
51+ name: "double dot only",
52+ input: "..",
53+ expected: "download",
54+ },
55+ {
56+ name: "empty string",
57+ input: "",
58+ expected: "download",
59+ },
60+ {
61+ name: "hidden file",
62+ input: ".bashrc",
63+ expected: ".bashrc",
64+ },
65+ {
66+ name: "windows style path",
67+ input: "..\\..\\Windows\\System32\\config",
68+ expected: "config",
69+ },
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ result := sanitizeFilename(tt.input)
75+ assert.Equal(t, tt.expected, result)
76+ })
77+ }
78+}
79+
80+func TestExtractFilename(t *testing.T) {
81+ tests := []struct {
82+ name string
83+ input string
84+ expected string
85+ }{
86+ {
87+ name: "file URI with path",
88+ input: "file:///home/user/Downloads/document.pdf",
89+ expected: "document.pdf",
90+ },
91+ {
92+ name: "file URI root",
93+ input: "file:///document.pdf",
94+ expected: "document.pdf",
95+ },
96+ {
97+ name: "plain path",
98+ input: "/home/user/Downloads/image.png",
99+ expected: "image.png",
100+ },
101+ {
102+ name: "filename only",
103+ input: "video.mp4",
104+ expected: "video.mp4",
105+ },
106+ {
107+ name: "empty string",
108+ input: "",
109+ expected: "download",
110+ },
111+ {
112+ name: "file URI with spaces",
113+ input: "file:///home/user/My Documents/file.txt",
114+ expected: "file.txt",
115+ },
116+ }
117+
118+ for _, tt := range tests {
119+ t.Run(tt.name, func(t *testing.T) {
120+ result := extractFilename(tt.input)
121+ assert.Equal(t, tt.expected, result)
122+ })
123+ }
124+}
125+
126+func TestNewDownloadHandler(t *testing.T) {
127+ t.Run("creates handler with path and nil event handler", func(t *testing.T) {
128+ handler := NewDownloadHandler("/tmp/downloads", nil)
129+
130+ assert.NotNil(t, handler)
131+ assert.Equal(t, "/tmp/downloads", handler.downloadPath)
132+ assert.Nil(t, handler.eventHandler)
133+ })
134+
135+ t.Run("creates handler with custom path", func(t *testing.T) {
136+ handler := NewDownloadHandler("/custom/path", nil)
137+
138+ assert.Equal(t, "/custom/path", handler.downloadPath)
139+ })
140+}
141+
142+func TestDownloadHandler_SetDownloadPath(t *testing.T) {
143+ handler := NewDownloadHandler("/initial/path", nil)
144+
145+ handler.SetDownloadPath("/new/path")
146+
147+ assert.Equal(t, "/new/path", handler.downloadPath)
148+}
@@ -0,0 +1,148 @@
1+package webkit
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestSanitizeFilename(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ expected string
14+ }{
15+ {
16+ name: "normal filename",
17+ input: "document.pdf",
18+ expected: "document.pdf",
19+ },
20+ {
21+ name: "filename with spaces",
22+ input: "my document.pdf",
23+ expected: "my document.pdf",
24+ },
25+ {
26+ name: "path traversal with parent dirs",
27+ input: "../../../etc/passwd",
28+ expected: "passwd",
29+ },
30+ {
31+ name: "path traversal hidden file",
32+ input: "../.ssh/id_rsa",
33+ expected: "id_rsa",
34+ },
35+ {
36+ name: "nested path",
37+ input: "foo/bar/baz.txt",
38+ expected: "baz.txt",
39+ },
40+ {
41+ name: "absolute path",
42+ input: "/etc/passwd",
43+ expected: "passwd",
44+ },
45+ {
46+ name: "dot only",
47+ input: ".",
48+ expected: "download",
49+ },
50+ {
51+ name: "double dot only",
52+ input: "..",
53+ expected: "download",
54+ },
55+ {
56+ name: "empty string",
57+ input: "",
58+ expected: "download",
59+ },
60+ {
61+ name: "hidden file",
62+ input: ".bashrc",
63+ expected: ".bashrc",
64+ },
65+ {
66+ name: "windows style path",
67+ input: "..\\..\\Windows\\System32\\config",
68+ expected: "config",
69+ },
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ result := sanitizeFilename(tt.input)
75+ assert.Equal(t, tt.expected, result)
76+ })
77+ }
78+}
79+
80+func TestExtractFilename(t *testing.T) {
81+ tests := []struct {
82+ name string
83+ input string
84+ expected string
85+ }{
86+ {
87+ name: "file URI with path",
88+ input: "file:///home/user/Downloads/document.pdf",
89+ expected: "document.pdf",
90+ },
91+ {
92+ name: "file URI root",
93+ input: "file:///document.pdf",
94+ expected: "document.pdf",
95+ },
96+ {
97+ name: "plain path",
98+ input: "/home/user/Downloads/image.png",
99+ expected: "image.png",
100+ },
101+ {
102+ name: "filename only",
103+ input: "video.mp4",
104+ expected: "video.mp4",
105+ },
106+ {
107+ name: "empty string",
108+ input: "",
109+ expected: "download",
110+ },
111+ {
112+ name: "file URI with spaces",
113+ input: "file:///home/user/My Documents/file.txt",
114+ expected: "file.txt",
115+ },
116+ }
117+
118+ for _, tt := range tests {
119+ t.Run(tt.name, func(t *testing.T) {
120+ result := extractFilename(tt.input)
121+ assert.Equal(t, tt.expected, result)
122+ })
123+ }
124+}
125+
126+func TestNewDownloadHandler(t *testing.T) {
127+ t.Run("creates handler with path and nil event handler", func(t *testing.T) {
128+ handler := NewDownloadHandler("/tmp/downloads", nil)
129+
130+ assert.NotNil(t, handler)
131+ assert.Equal(t, "/tmp/downloads", handler.downloadPath)
132+ assert.Nil(t, handler.eventHandler)
133+ })
134+
135+ t.Run("creates handler with custom path", func(t *testing.T) {
136+ handler := NewDownloadHandler("/custom/path", nil)
137+
138+ assert.Equal(t, "/custom/path", handler.downloadPath)
139+ })
140+}
141+
142+func TestDownloadHandler_SetDownloadPath(t *testing.T) {
143+ handler := NewDownloadHandler("/initial/path", nil)
144+
145+ handler.SetDownloadPath("/new/path")
146+
147+ assert.Equal(t, "/new/path", handler.downloadPath)
148+}
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)
A internal/infrastructure/xdg/adapter_test.go +63 -0
@@ -0,0 +1,63 @@
1+package xdg
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestAdapter_DownloadDir(t *testing.T) {
13+ adapter := New()
14+
15+ t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) {
16+ // Save and restore original env.
17+ original := os.Getenv("XDG_DOWNLOAD_DIR")
18+ defer func() {
19+ if original == "" {
20+ os.Unsetenv("XDG_DOWNLOAD_DIR")
21+ } else {
22+ os.Setenv("XDG_DOWNLOAD_DIR", original)
23+ }
24+ }()
25+
26+ os.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads")
27+
28+ dir, err := adapter.DownloadDir()
29+
30+ require.NoError(t, err)
31+ assert.Equal(t, "/custom/downloads", dir)
32+ })
33+
34+ t.Run("falls back to ~/Downloads when XDG_DOWNLOAD_DIR not set", func(t *testing.T) {
35+ // Save and restore original env.
36+ original := os.Getenv("XDG_DOWNLOAD_DIR")
37+ defer func() {
38+ if original == "" {
39+ os.Unsetenv("XDG_DOWNLOAD_DIR")
40+ } else {
41+ os.Setenv("XDG_DOWNLOAD_DIR", original)
42+ }
43+ }()
44+
45+ os.Unsetenv("XDG_DOWNLOAD_DIR")
46+
47+ dir, err := adapter.DownloadDir()
48+
49+ require.NoError(t, err)
50+
51+ home, err := os.UserHomeDir()
52+ require.NoError(t, err)
53+
54+ expected := filepath.Join(home, "Downloads")
55+ assert.Equal(t, expected, dir)
56+ })
57+}
58+
59+func TestNew(t *testing.T) {
60+ adapter := New()
61+
62+ assert.NotNil(t, adapter)
63+}
@@ -0,0 +1,63 @@
1+package xdg
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestAdapter_DownloadDir(t *testing.T) {
13+ adapter := New()
14+
15+ t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) {
16+ // Save and restore original env.
17+ original := os.Getenv("XDG_DOWNLOAD_DIR")
18+ defer func() {
19+ if original == "" {
20+ os.Unsetenv("XDG_DOWNLOAD_DIR")
21+ } else {
22+ os.Setenv("XDG_DOWNLOAD_DIR", original)
23+ }
24+ }()
25+
26+ os.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads")
27+
28+ dir, err := adapter.DownloadDir()
29+
30+ require.NoError(t, err)
31+ assert.Equal(t, "/custom/downloads", dir)
32+ })
33+
34+ t.Run("falls back to ~/Downloads when XDG_DOWNLOAD_DIR not set", func(t *testing.T) {
35+ // Save and restore original env.
36+ original := os.Getenv("XDG_DOWNLOAD_DIR")
37+ defer func() {
38+ if original == "" {
39+ os.Unsetenv("XDG_DOWNLOAD_DIR")
40+ } else {
41+ os.Setenv("XDG_DOWNLOAD_DIR", original)
42+ }
43+ }()
44+
45+ os.Unsetenv("XDG_DOWNLOAD_DIR")
46+
47+ dir, err := adapter.DownloadDir()
48+
49+ require.NoError(t, err)
50+
51+ home, err := os.UserHomeDir()
52+ require.NoError(t, err)
53+
54+ expected := filepath.Join(home, "Downloads")
55+ assert.Equal(t, expected, dir)
56+ })
57+}
58+
59+func TestNew(t *testing.T) {
60+ adapter := New()
61+
62+ assert.NotNil(t, adapter)
63+}
M internal/ui/app.go +63 -0
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "os"
8+ "path/filepath"
89 "sync"
910
1011 "github.com/bnema/dumber/internal/application/port"
@@ -240,6 +241,7 @@ func (a *App) onActivate(ctx context.Context) {
240241 a.initAppToasterOverlay()
241242 a.initFocusAndBorderOverlay()
242243 a.initAccentPicker(ctx)
244+ a.initDownloadHandler(ctx)
243245
244246 a.initCoordinators(ctx)
245247 a.initKeyboardHandler(ctx)
@@ -453,6 +455,67 @@ func (a *App) initAccentPicker(ctx context.Context) {
453455 log.Debug().Msg("accent picker initialized")
454456 }
455457
458+func (a *App) initDownloadHandler(ctx context.Context) {
459+ log := logging.FromContext(ctx)
460+
461+ if a.deps == nil || a.deps.WebContext == nil {
462+ log.Debug().Msg("WebContext not available, skipping download handler")
463+ return
464+ }
465+
466+ // Determine download path from config, fallback to XDG.
467+ downloadPath := ""
468+ if a.deps.Config != nil {
469+ downloadPath = a.deps.Config.Downloads.Path
470+ }
471+ if downloadPath == "" && a.deps.XDG != nil {
472+ var err error
473+ downloadPath, err = a.deps.XDG.DownloadDir()
474+ if err != nil {
475+ log.Warn().Err(err).Msg("failed to get XDG download dir")
476+ }
477+ }
478+ if downloadPath == "" {
479+ // Final fallback to ~/Downloads.
480+ home, _ := os.UserHomeDir()
481+ downloadPath = filepath.Join(home, "Downloads")
482+ }
483+
484+ // Create download event adapter to show toasts.
485+ eventAdapter := &downloadEventAdapter{app: a}
486+
487+ // Create and wire the download handler.
488+ handler := webkit.NewDownloadHandler(downloadPath, eventAdapter)
489+ a.deps.WebContext.SetDownloadHandler(ctx, handler)
490+
491+ log.Info().Str("path", downloadPath).Msg("download handler initialized")
492+}
493+
494+// downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
495+type downloadEventAdapter struct {
496+ app *App
497+}
498+
499+func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
500+ // Must schedule on GTK main thread.
501+ cb := glib.SourceFunc(func(_ uintptr) bool {
502+ if d.app.appToaster == nil {
503+ return false
504+ }
505+
506+ switch event.Type {
507+ case port.DownloadEventStarted:
508+ d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo)
509+ case port.DownloadEventFinished:
510+ d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
511+ case port.DownloadEventFailed:
512+ d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError)
513+ }
514+ return false
515+ })
516+ glib.IdleAdd(&cb, 0)
517+}
518+
456519 func (a *App) initKeyboardHandler(ctx context.Context) {
457520 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
458521 return
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "os"7 "os"
8+ "path/filepath"
8 "sync"9 "sync"
9 10
10 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
@@ -240,6 +241,7 @@ func (a *App) onActivate(ctx context.Context) {
240 a.initAppToasterOverlay()241 a.initAppToasterOverlay()
241 a.initFocusAndBorderOverlay()242 a.initFocusAndBorderOverlay()
242 a.initAccentPicker(ctx)243 a.initAccentPicker(ctx)
244+ a.initDownloadHandler(ctx)
243 245
244 a.initCoordinators(ctx)246 a.initCoordinators(ctx)
245 a.initKeyboardHandler(ctx)247 a.initKeyboardHandler(ctx)
@@ -453,6 +455,67 @@ func (a *App) initAccentPicker(ctx context.Context) {
453 log.Debug().Msg("accent picker initialized")455 log.Debug().Msg("accent picker initialized")
454 }456 }
455 457
458+func (a *App) initDownloadHandler(ctx context.Context) {
459+ log := logging.FromContext(ctx)
460+
461+ if a.deps == nil || a.deps.WebContext == nil {
462+ log.Debug().Msg("WebContext not available, skipping download handler")
463+ return
464+ }
465+
466+ // Determine download path from config, fallback to XDG.
467+ downloadPath := ""
468+ if a.deps.Config != nil {
469+ downloadPath = a.deps.Config.Downloads.Path
470+ }
471+ if downloadPath == "" && a.deps.XDG != nil {
472+ var err error
473+ downloadPath, err = a.deps.XDG.DownloadDir()
474+ if err != nil {
475+ log.Warn().Err(err).Msg("failed to get XDG download dir")
476+ }
477+ }
478+ if downloadPath == "" {
479+ // Final fallback to ~/Downloads.
480+ home, _ := os.UserHomeDir()
481+ downloadPath = filepath.Join(home, "Downloads")
482+ }
483+
484+ // Create download event adapter to show toasts.
485+ eventAdapter := &downloadEventAdapter{app: a}
486+
487+ // Create and wire the download handler.
488+ handler := webkit.NewDownloadHandler(downloadPath, eventAdapter)
489+ a.deps.WebContext.SetDownloadHandler(ctx, handler)
490+
491+ log.Info().Str("path", downloadPath).Msg("download handler initialized")
492+}
493+
494+// downloadEventAdapter implements port.DownloadEventHandler and shows toasts.
495+type downloadEventAdapter struct {
496+ app *App
497+}
498+
499+func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.DownloadEvent) {
500+ // Must schedule on GTK main thread.
501+ cb := glib.SourceFunc(func(_ uintptr) bool {
502+ if d.app.appToaster == nil {
503+ return false
504+ }
505+
506+ switch event.Type {
507+ case port.DownloadEventStarted:
508+ d.app.appToaster.Show(ctx, "Download started: "+event.Filename, component.ToastInfo)
509+ case port.DownloadEventFinished:
510+ d.app.appToaster.Show(ctx, "Download complete: "+event.Filename, component.ToastSuccess)
511+ case port.DownloadEventFailed:
512+ d.app.appToaster.Show(ctx, "Download failed: "+event.Filename, component.ToastError)
513+ }
514+ return false
515+ })
516+ glib.IdleAdd(&cb, 0)
517+}
518+
456 func (a *App) initKeyboardHandler(ctx context.Context) {519 func (a *App) initKeyboardHandler(ctx context.Context) {
457 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {520 if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil {
458 return521 return
M internal/ui/deps.go +3 -0
@@ -31,6 +31,9 @@ type Dependencies struct {
3131 ColorResolver port.ColorSchemeResolver
3232 AdwaitaDetector *colorscheme.AdwaitaDetector
3333
34+ // XDG paths
35+ XDG port.XDGPaths
36+
3437 // WebKit infrastructure
3538 WebContext *webkit.WebKitContext
3639 Pool *webkit.WebViewPool
@@ -31,6 +31,9 @@ type Dependencies struct {
31 ColorResolver port.ColorSchemeResolver31 ColorResolver port.ColorSchemeResolver
32 AdwaitaDetector *colorscheme.AdwaitaDetector32 AdwaitaDetector *colorscheme.AdwaitaDetector
33 33
34+ // XDG paths
35+ XDG port.XDGPaths
36+
34 // WebKit infrastructure37 // WebKit infrastructure
35 WebContext *webkit.WebKitContext38 WebContext *webkit.WebKitContext
36 Pool *webkit.WebViewPool39 Pool *webkit.WebViewPool

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