Merge pull request #112 from bnema/feat/download-handling
feat: add file download handling with toaster notifications
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. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### 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 | 9 | - **Print shortcut**: `Ctrl+Shift+P` opens the print dialog for the current page. Fixes #99. | |
| 9 | 10 | ||
| 10 | 11 | ### Changed | |
| @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. | |||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | ### Added | 7 | ### 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 | ### Changed | 11 | ### Changed | ||
M cmd/dumber/main.go +2 -0
| @@ -25,6 +25,7 @@ import ( | |||
| 25 | 25 | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| 26 | 26 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 27 | 27 | "github.com/bnema/dumber/internal/infrastructure/updater" | |
| 28 | + | "github.com/bnema/dumber/internal/infrastructure/xdg" | |
| 28 | 29 | "github.com/bnema/dumber/internal/logging" | |
| 29 | 30 | "github.com/bnema/dumber/internal/ui" | |
| 30 | 31 | "github.com/bnema/dumber/internal/ui/theme" | |
| @@ -428,6 +429,7 @@ func buildUIDependencies( | |||
| 428 | 429 | Theme: themeManager, | |
| 429 | 430 | ColorResolver: colorResolver, | |
| 430 | 431 | AdwaitaDetector: adwaitaDetector, | |
| 432 | + | XDG: xdg.New(), | |
| 431 | 433 | WebContext: stack.Context, | |
| 432 | 434 | Pool: stack.Pool, | |
| 433 | 435 | 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 | |||
| 597 | 597 | ||
| 598 | 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 | 617 | ## Environment Variables | |
| 601 | 618 | ||
| 602 | 619 | 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 Variables | 617 | ## 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 { | |||
| 14 | 14 | // ManDir returns the user man page directory (man1 section). | |
| 15 | 15 | // Typically $XDG_DATA_HOME/man/man1 or ~/.local/share/man/man1. | |
| 16 | 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 | } | |
| @@ -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 { | |||
| 47 | 47 | "Session", | |
| 48 | 48 | "Omnibox", | |
| 49 | 49 | "Content Filtering", | |
| 50 | + | "Clipboard", | |
| 50 | 51 | "Rendering", | |
| 51 | 52 | "Media", | |
| 52 | 53 | "Update", | |
| 54 | + | "Downloads", | |
| 53 | 55 | "Debug", | |
| 54 | 56 | "Performance", | |
| 55 | 57 | "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 { | |||
| 311 | 311 | AutoDownload: false, // Conservative: don't auto-download by default | |
| 312 | 312 | 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 | ||
| @@ -311,6 +311,9 @@ func DefaultConfig() *Config { | |||||
| 311 | AutoDownload: false, // Conservative: don't auto-download by default | 311 | AutoDownload: false, // Conservative: don't auto-download by default | ||
| 312 | NotifyOnNewSettings: true, // Show toast when new config settings available | 312 | 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() { | |||
| 365 | 365 | m.setSessionDefaults(defaults) | |
| 366 | 366 | m.setUpdateDefaults(defaults) | |
| 367 | 367 | m.setPerformanceDefaults(defaults) | |
| 368 | + | m.setDownloadsDefaults(defaults) | |
| 368 | 369 | } | |
| 369 | 370 | ||
| 370 | 371 | func (m *Manager) setHistoryDefaults(defaults *Config) { | |
| @@ -533,6 +534,10 @@ func (m *Manager) setPerformanceDefaults(defaults *Config) { | |||
| 533 | 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 | 541 | // New returns a new default configuration instance. | |
| 537 | 542 | // This is a convenience function for getting default config without the full manager. | |
| 538 | 543 | 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 { | |||
| 35 | 35 | Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"` | |
| 36 | 36 | // Update controls automatic update checking and downloading. | |
| 37 | 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 | 42 | // RenderingMode selects GPU vs CPU rendering. | |
| @@ -372,6 +374,13 @@ type UpdateConfig struct { | |||
| 372 | 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 | 384 | // WorkspaceConfig captures layout, pane, and tab behavior preferences. | |
| 376 | 385 | type WorkspaceConfig struct { | |
| 377 | 386 | // 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 ( | |||
| 25 | 25 | SectionRuntime = "Runtime" | |
| 26 | 26 | SectionDatabase = "Database" | |
| 27 | 27 | SectionSearch = "Search" | |
| 28 | + | SectionDownloads = "Downloads" | |
| 28 | 29 | ) | |
| 29 | 30 | ||
| 30 | 31 | // SchemaProvider implements port.ConfigSchemaProvider. | |
| @@ -92,6 +93,9 @@ func (p *SchemaProvider) GetSchema() []entity.ConfigKeyInfo { | |||
| 92 | 93 | // Database section | |
| 93 | 94 | keys = append(keys, p.getDatabaseKeys()...) | |
| 94 | 95 | ||
| 96 | + | // Downloads section | |
| 97 | + | keys = append(keys, p.getDownloadsKeys(defaults)...) | |
| 98 | + | ||
| 95 | 99 | 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 | + | } | |
| @@ -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 section | 93 | // 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 keys | 99 | 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 { | |||
| 26 | 26 | dataDir string | |
| 27 | 27 | cacheDir string | |
| 28 | 28 | ||
| 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 | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | // NewWebKitContext creates and initializes a WebKitContext with a persistent NetworkSession. | |
| @@ -236,3 +237,27 @@ func (c *WebKitContext) Close() error { | |||
| 236 | 237 | c.logger.Debug().Msg("webkit context closed") | |
| 237 | 238 | 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 | + | } | |
| @@ -26,9 +26,10 @@ type WebKitContext struct { | |||||
| 26 | dataDir string | 26 | dataDir string | ||
| 27 | cacheDir string | 27 | cacheDir string | ||
| 28 | 28 | ||||
| 29 | - | logger zerolog.Logger | 29 | + | logger zerolog.Logger |
| 30 | - | mu sync.RWMutex | 30 | + | mu sync.RWMutex |
| 31 | - | initialized bool | 31 | + | 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 nil | 238 | 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) { | |||
| 72 | 72 | 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 | 89 | var _ port.XDGPaths = (*Adapter)(nil) | |
| @@ -72,4 +72,18 @@ func (a *Adapter) ManDir() (string, error) { | |||||
| 72 | return filepath.Join(dataHome, "man", "man1"), nil | 72 | 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 ( | |||
| 5 | 5 | "errors" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | + | "path/filepath" | |
| 8 | 9 | "sync" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -240,6 +241,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 240 | 241 | a.initAppToasterOverlay() | |
| 241 | 242 | a.initFocusAndBorderOverlay() | |
| 242 | 243 | a.initAccentPicker(ctx) | |
| 244 | + | a.initDownloadHandler(ctx) | |
| 243 | 245 | ||
| 244 | 246 | a.initCoordinators(ctx) | |
| 245 | 247 | a.initKeyboardHandler(ctx) | |
| @@ -453,6 +455,67 @@ func (a *App) initAccentPicker(ctx context.Context) { | |||
| 453 | 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 | 519 | func (a *App) initKeyboardHandler(ctx context.Context) { | |
| 457 | 520 | if a.mainWindow == nil || a.deps == nil || a.deps.Config == nil { | |
| 458 | 521 | 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 | return | 521 | return | ||
M internal/ui/deps.go +3 -0
| @@ -31,6 +31,9 @@ type Dependencies struct { | |||
| 31 | 31 | ColorResolver port.ColorSchemeResolver | |
| 32 | 32 | AdwaitaDetector *colorscheme.AdwaitaDetector | |
| 33 | 33 | ||
| 34 | + | // XDG paths | |
| 35 | + | XDG port.XDGPaths | |
| 36 | + | ||
| 34 | 37 | // WebKit infrastructure | |
| 35 | 38 | WebContext *webkit.WebKitContext | |
| 36 | 39 | Pool *webkit.WebViewPool | |
| @@ -31,6 +31,9 @@ type Dependencies struct { | |||||
| 31 | ColorResolver port.ColorSchemeResolver | 31 | ColorResolver port.ColorSchemeResolver | ||
| 32 | AdwaitaDetector *colorscheme.AdwaitaDetector | 32 | AdwaitaDetector *colorscheme.AdwaitaDetector | ||
| 33 | 33 | ||||
| 34 | + | // XDG paths | |||
| 35 | + | XDG port.XDGPaths | |||
| 36 | + | ||||
| 34 | // WebKit infrastructure | 37 | // WebKit infrastructure | ||
| 35 | WebContext *webkit.WebKitContext | 38 | WebContext *webkit.WebKitContext | ||
| 36 | Pool *webkit.WebViewPool | 39 | Pool *webkit.WebViewPool | ||