feat(update): add auto-update system with GitHub release checking
- commit
- 5a26d2163358de57158fba0195e7bc89b7c116d8
- author
- brice · · 2025-12-25 16:15 UTC
- parent
- fe57548
- signature
- unsigned
17 files · +1224 -7
M .goreleaser.yaml +12 -5
| @@ -33,14 +33,14 @@ archives: | |||
| 33 | 33 | - id: default | |
| 34 | 34 | name_template: >- | |
| 35 | 35 | {{ .ProjectName }}_ | |
| 36 | - | {{- .Version }}_ | |
| 37 | 36 | {{- .Os }}_ | |
| 38 | 37 | {{- if eq .Arch "amd64" }}x86_64 | |
| 39 | 38 | {{- else if eq .Arch "386" }}i386 | |
| 40 | 39 | {{- else }}{{ .Arch }}{{ end }} | |
| 40 | + | wrap_in_directory: "{{ .ProjectName }}_{{ .Version }}" | |
| 41 | 41 | format_overrides: | |
| 42 | 42 | - goos: linux | |
| 43 | - | format: tar.gz | |
| 43 | + | formats: ['tar.gz'] | |
| 44 | 44 | files: | |
| 45 | 45 | - LICENSE | |
| 46 | 46 | - README.md | |
| @@ -109,9 +109,16 @@ release: | |||
| 109 | 109 | ||
| 110 | 110 | ### Linux (x86_64/amd64) | |
| 111 | 111 | ```bash | |
| 112 | - | wget https://github.com/bnema/dumber/releases/download/{{.Tag}}/dumber_{{.Version}}_linux_x86_64.tar.gz | |
| 113 | - | tar -xzf dumber_{{.Version}}_linux_x86_64.tar.gz | |
| 114 | - | sudo install -m 755 dumber /usr/local/bin/ | |
| 112 | + | # Download latest release | |
| 113 | + | wget https://github.com/bnema/dumber/releases/latest/download/dumber_linux_x86_64.tar.gz | |
| 114 | + | ||
| 115 | + | # Extract and install to ~/.local/bin (enables auto-updates) | |
| 116 | + | tar -xzf dumber_linux_x86_64.tar.gz | |
| 117 | + | mkdir -p ~/.local/bin | |
| 118 | + | install -m 755 dumber_*/dumber ~/.local/bin/ | |
| 119 | + | ||
| 120 | + | # Ensure ~/.local/bin is in your PATH (add to ~/.bashrc or ~/.zshrc if needed) | |
| 121 | + | # export PATH="$HOME/.local/bin:$PATH" | |
| 115 | 122 | ``` | |
| 116 | 123 | ||
| 117 | 124 | ### Dependencies | |
| @@ -33,14 +33,14 @@ archives: | |||||
| 33 | - id: default | 33 | - id: default | ||
| 34 | name_template: >- | 34 | name_template: >- | ||
| 35 | {{ .ProjectName }}_ | 35 | {{ .ProjectName }}_ | ||
| 36 | - | {{- .Version }}_ | |||
| 37 | {{- .Os }}_ | 36 | {{- .Os }}_ | ||
| 38 | {{- if eq .Arch "amd64" }}x86_64 | 37 | {{- if eq .Arch "amd64" }}x86_64 | ||
| 39 | {{- else if eq .Arch "386" }}i386 | 38 | {{- else if eq .Arch "386" }}i386 | ||
| 40 | {{- else }}{{ .Arch }}{{ end }} | 39 | {{- else }}{{ .Arch }}{{ end }} | ||
| 40 | + | wrap_in_directory: "{{ .ProjectName }}_{{ .Version }}" | |||
| 41 | format_overrides: | 41 | format_overrides: | ||
| 42 | - goos: linux | 42 | - goos: linux | ||
| 43 | - | format: tar.gz | 43 | + | formats: ['tar.gz'] |
| 44 | files: | 44 | files: | ||
| 45 | - LICENSE | 45 | - LICENSE | ||
| 46 | - README.md | 46 | - README.md | ||
| @@ -109,9 +109,16 @@ release: | |||||
| 109 | 109 | ||||
| 110 | ### Linux (x86_64/amd64) | 110 | ### Linux (x86_64/amd64) | ||
| 111 | ```bash | 111 | ```bash | ||
| 112 | - | wget https://github.com/bnema/dumber/releases/download/{{.Tag}}/dumber_{{.Version}}_linux_x86_64.tar.gz | 112 | + | # Download latest release |
| 113 | - | tar -xzf dumber_{{.Version}}_linux_x86_64.tar.gz | 113 | + | wget https://github.com/bnema/dumber/releases/latest/download/dumber_linux_x86_64.tar.gz |
| 114 | - | sudo install -m 755 dumber /usr/local/bin/ | 114 | + | |
| 115 | + | # Extract and install to ~/.local/bin (enables auto-updates) | |||
| 116 | + | tar -xzf dumber_linux_x86_64.tar.gz | |||
| 117 | + | mkdir -p ~/.local/bin | |||
| 118 | + | install -m 755 dumber_*/dumber ~/.local/bin/ | |||
| 119 | + | ||||
| 120 | + | # Ensure ~/.local/bin is in your PATH (add to ~/.bashrc or ~/.zshrc if needed) | |||
| 121 | + | # export PATH="$HOME/.local/bin:$PATH" | |||
| 115 | ``` | 122 | ``` | ||
| 116 | 123 | ||||
| 117 | ### Dependencies | 124 | ### Dependencies | ||
M CHANGELOG.md +7 -0
| @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### Added | |
| 8 | + | - **Auto-update system**: Automatic update checking and self-updating binary replacement following clean architecture. | |
| 9 | + | - **Update check on startup**: Async check against GitHub releases API (configurable via `update.enable_on_startup`, default: true). | |
| 10 | + | - **Toast notifications**: Non-intrusive auto-dismiss notifications for update availability. | |
| 11 | + | - **Auto-download**: Optional background download with binary staging (configurable via `update.auto_download`, default: false). | |
| 12 | + | - **Binary self-update**: Atomic binary replacement on clean exit with `.old` backup. Only works if binary is user-writable (e.g., `~/.local/bin/`). | |
| 13 | + | - **CLI update check**: `dumber about` now displays update availability. | |
| 14 | + | - **Version-less release archives**: Goreleaser now produces `dumber_linux_x86_64.tar.gz` (version in directory inside) enabling stable GitHub `/releases/latest/download/` URLs. | |
| 8 | 15 | - **Session management & resurrection**: Zellij-inspired session management system allowing users to save, list, and restore browser sessions. | |
| 9 | 16 | - **Automatic session snapshots**: Debounced state saving (configurable interval, default 5s) captures tabs, panes, splits, stacks, URLs, titles, and zoom levels. | |
| 10 | 17 | - **Session Manager modal**: Access via `Ctrl+O → s/w` or `Ctrl+Shift+S` to browse and restore sessions with inline preview of tabs and panes. | |
| @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. | |||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | ### Added | 7 | ### Added | ||
| 8 | + | - **Auto-update system**: Automatic update checking and self-updating binary replacement following clean architecture. | |||
| 9 | + | - **Update check on startup**: Async check against GitHub releases API (configurable via `update.enable_on_startup`, default: true). | |||
| 10 | + | - **Toast notifications**: Non-intrusive auto-dismiss notifications for update availability. | |||
| 11 | + | - **Auto-download**: Optional background download with binary staging (configurable via `update.auto_download`, default: false). | |||
| 12 | + | - **Binary self-update**: Atomic binary replacement on clean exit with `.old` backup. Only works if binary is user-writable (e.g., `~/.local/bin/`). | |||
| 13 | + | - **CLI update check**: `dumber about` now displays update availability. | |||
| 14 | + | - **Version-less release archives**: Goreleaser now produces `dumber_linux_x86_64.tar.gz` (version in directory inside) enabling stable GitHub `/releases/latest/download/` URLs. | |||
| 8 | - **Session management & resurrection**: Zellij-inspired session management system allowing users to save, list, and restore browser sessions. | 15 | - **Session management & resurrection**: Zellij-inspired session management system allowing users to save, list, and restore browser sessions. | ||
| 9 | - **Automatic session snapshots**: Debounced state saving (configurable interval, default 5s) captures tabs, panes, splits, stacks, URLs, titles, and zoom levels. | 16 | - **Automatic session snapshots**: Debounced state saving (configurable interval, default 5s) captures tabs, panes, splits, stacks, URLs, titles, and zoom levels. | ||
| 10 | - **Session Manager modal**: Access via `Ctrl+O → s/w` or `Ctrl+Shift+S` to browse and restore sessions with inline preview of tabs and panes. | 17 | - **Session Manager modal**: Access via `Ctrl+O → s/w` or `Ctrl+Shift+S` to browse and restore sessions with inline preview of tabs and panes. | ||
M cmd/dumber/main.go +21 -2
| @@ -25,6 +25,7 @@ import ( | |||
| 25 | 25 | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| 26 | 26 | "github.com/bnema/dumber/internal/infrastructure/media" | |
| 27 | 27 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 28 | + | "github.com/bnema/dumber/internal/infrastructure/updater" | |
| 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" | |
| @@ -118,7 +119,13 @@ func runGUI() int { | |||
| 118 | 119 | repos := createRepositories(db) | |
| 119 | 120 | ||
| 120 | 121 | stateDir, _ := config.GetStateDir() | |
| 121 | - | useCases := createUseCases(repos, cfg, stateDir) | |
| 122 | + | buildInfo := build.Info{ | |
| 123 | + | Version: version, | |
| 124 | + | Commit: commit, | |
| 125 | + | BuildDate: buildDate, | |
| 126 | + | GoVersion: runtime.Version(), | |
| 127 | + | } | |
| 128 | + | useCases := createUseCases(repos, cfg, stateDir, buildInfo) | |
| 122 | 129 | ||
| 123 | 130 | // Auto-restore: find last restorable session if enabled and no explicit restore requested | |
| 124 | 131 | if cfg.Session.AutoRestore && restoreSessionID == "" { | |
| @@ -283,11 +290,13 @@ type useCases struct { | |||
| 283 | 290 | copyURL *usecase.CopyURLUseCase | |
| 284 | 291 | snapshot *usecase.SnapshotSessionUseCase | |
| 285 | 292 | lastRestorable *usecase.GetLastRestorableSessionUseCase | |
| 293 | + | checkUpdate *usecase.CheckUpdateUseCase | |
| 294 | + | applyUpdate *usecase.ApplyUpdateUseCase | |
| 286 | 295 | clipboard port.Clipboard | |
| 287 | 296 | favicon *favicon.Service | |
| 288 | 297 | } | |
| 289 | 298 | ||
| 290 | - | func createUseCases(repos *repositories, cfg *config.Config, stateDir string) *useCases { | |
| 299 | + | func createUseCases(repos *repositories, cfg *config.Config, stateDir string, buildInfo build.Info) *useCases { | |
| 291 | 300 | const idAlphabetSize = 26 | |
| 292 | 301 | idCounter := uint64(0) | |
| 293 | 302 | idGenerator := func() string { | |
| @@ -297,11 +306,17 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string) *u | |||
| 297 | 306 | ||
| 298 | 307 | clipboardAdapter := clipboard.New() | |
| 299 | 308 | faviconCacheDir, _ := config.GetFaviconCacheDir() | |
| 309 | + | cacheDir, _ := config.GetXDGDirs() | |
| 300 | 310 | defaultZoom := cfg.DefaultWebpageZoom | |
| 301 | 311 | ||
| 302 | 312 | // Create LRU cache for zoom levels to avoid database queries on repeat visits | |
| 303 | 313 | zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize) | |
| 304 | 314 | ||
| 315 | + | // Create update infrastructure | |
| 316 | + | updateChecker := updater.NewGitHubChecker() | |
| 317 | + | updateDownloader := updater.NewGitHubDownloader() | |
| 318 | + | updateApplier := updater.NewApplier(stateDir) | |
| 319 | + | ||
| 305 | 320 | return &useCases{ | |
| 306 | 321 | tabs: usecase.NewManageTabsUseCase(idGenerator), | |
| 307 | 322 | panes: usecase.NewManagePanesUseCase(idGenerator), | |
| @@ -312,6 +327,8 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string) *u | |||
| 312 | 327 | copyURL: usecase.NewCopyURLUseCase(clipboardAdapter), | |
| 313 | 328 | snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState), | |
| 314 | 329 | lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir), | |
| 330 | + | checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo), | |
| 331 | + | applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, cacheDir.CacheHome), | |
| 315 | 332 | clipboard: clipboardAdapter, | |
| 316 | 333 | favicon: favicon.NewService(faviconCacheDir), | |
| 317 | 334 | } | |
| @@ -356,5 +373,7 @@ func buildUIDependencies( | |||
| 356 | 373 | SessionStateRepo: repos.sessionState, | |
| 357 | 374 | CurrentSessionID: currentSessionID, | |
| 358 | 375 | SnapshotUC: uc.snapshot, | |
| 376 | + | CheckUpdateUC: uc.checkUpdate, | |
| 377 | + | ApplyUpdateUC: uc.applyUpdate, | |
| 359 | 378 | } | |
| 360 | 379 | } | |
| @@ -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/media" | 26 | "github.com/bnema/dumber/internal/infrastructure/media" | ||
| 27 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | 27 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | ||
| 28 | + | "github.com/bnema/dumber/internal/infrastructure/updater" | |||
| 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" | ||
| @@ -118,7 +119,13 @@ func runGUI() int { | |||||
| 118 | repos := createRepositories(db) | 119 | repos := createRepositories(db) | ||
| 119 | 120 | ||||
| 120 | stateDir, _ := config.GetStateDir() | 121 | stateDir, _ := config.GetStateDir() | ||
| 121 | - | useCases := createUseCases(repos, cfg, stateDir) | 122 | + | buildInfo := build.Info{ |
| 123 | + | Version: version, | |||
| 124 | + | Commit: commit, | |||
| 125 | + | BuildDate: buildDate, | |||
| 126 | + | GoVersion: runtime.Version(), | |||
| 127 | + | } | |||
| 128 | + | useCases := createUseCases(repos, cfg, stateDir, buildInfo) | |||
| 122 | 129 | ||||
| 123 | // Auto-restore: find last restorable session if enabled and no explicit restore requested | 130 | // Auto-restore: find last restorable session if enabled and no explicit restore requested | ||
| 124 | if cfg.Session.AutoRestore && restoreSessionID == "" { | 131 | if cfg.Session.AutoRestore && restoreSessionID == "" { | ||
| @@ -283,11 +290,13 @@ type useCases struct { | |||||
| 283 | copyURL *usecase.CopyURLUseCase | 290 | copyURL *usecase.CopyURLUseCase | ||
| 284 | snapshot *usecase.SnapshotSessionUseCase | 291 | snapshot *usecase.SnapshotSessionUseCase | ||
| 285 | lastRestorable *usecase.GetLastRestorableSessionUseCase | 292 | lastRestorable *usecase.GetLastRestorableSessionUseCase | ||
| 293 | + | checkUpdate *usecase.CheckUpdateUseCase | |||
| 294 | + | applyUpdate *usecase.ApplyUpdateUseCase | |||
| 286 | clipboard port.Clipboard | 295 | clipboard port.Clipboard | ||
| 287 | favicon *favicon.Service | 296 | favicon *favicon.Service | ||
| 288 | } | 297 | } | ||
| 289 | 298 | ||||
| 290 | - | func createUseCases(repos *repositories, cfg *config.Config, stateDir string) *useCases { | 299 | + | func createUseCases(repos *repositories, cfg *config.Config, stateDir string, buildInfo build.Info) *useCases { |
| 291 | const idAlphabetSize = 26 | 300 | const idAlphabetSize = 26 | ||
| 292 | idCounter := uint64(0) | 301 | idCounter := uint64(0) | ||
| 293 | idGenerator := func() string { | 302 | idGenerator := func() string { | ||
| @@ -297,11 +306,17 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string) *u | |||||
| 297 | 306 | ||||
| 298 | clipboardAdapter := clipboard.New() | 307 | clipboardAdapter := clipboard.New() | ||
| 299 | faviconCacheDir, _ := config.GetFaviconCacheDir() | 308 | faviconCacheDir, _ := config.GetFaviconCacheDir() | ||
| 309 | + | cacheDir, _ := config.GetXDGDirs() | |||
| 300 | defaultZoom := cfg.DefaultWebpageZoom | 310 | defaultZoom := cfg.DefaultWebpageZoom | ||
| 301 | 311 | ||||
| 302 | // Create LRU cache for zoom levels to avoid database queries on repeat visits | 312 | // Create LRU cache for zoom levels to avoid database queries on repeat visits | ||
| 303 | zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize) | 313 | zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize) | ||
| 304 | 314 | ||||
| 315 | + | // Create update infrastructure | |||
| 316 | + | updateChecker := updater.NewGitHubChecker() | |||
| 317 | + | updateDownloader := updater.NewGitHubDownloader() | |||
| 318 | + | updateApplier := updater.NewApplier(stateDir) | |||
| 319 | + | ||||
| 305 | return &useCases{ | 320 | return &useCases{ | ||
| 306 | tabs: usecase.NewManageTabsUseCase(idGenerator), | 321 | tabs: usecase.NewManageTabsUseCase(idGenerator), | ||
| 307 | panes: usecase.NewManagePanesUseCase(idGenerator), | 322 | panes: usecase.NewManagePanesUseCase(idGenerator), | ||
| @@ -312,6 +327,8 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string) *u | |||||
| 312 | copyURL: usecase.NewCopyURLUseCase(clipboardAdapter), | 327 | copyURL: usecase.NewCopyURLUseCase(clipboardAdapter), | ||
| 313 | snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState), | 328 | snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState), | ||
| 314 | lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir), | 329 | lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir), | ||
| 330 | + | checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo), | |||
| 331 | + | applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, cacheDir.CacheHome), | |||
| 315 | clipboard: clipboardAdapter, | 332 | clipboard: clipboardAdapter, | ||
| 316 | favicon: favicon.NewService(faviconCacheDir), | 333 | favicon: favicon.NewService(faviconCacheDir), | ||
| 317 | } | 334 | } | ||
| @@ -356,5 +373,7 @@ func buildUIDependencies( | |||||
| 356 | SessionStateRepo: repos.sessionState, | 373 | SessionStateRepo: repos.sessionState, | ||
| 357 | CurrentSessionID: currentSessionID, | 374 | CurrentSessionID: currentSessionID, | ||
| 358 | SnapshotUC: uc.snapshot, | 375 | SnapshotUC: uc.snapshot, | ||
| 376 | + | CheckUpdateUC: uc.checkUpdate, | |||
| 377 | + | ApplyUpdateUC: uc.applyUpdate, | |||
| 359 | } | 378 | } | ||
| 360 | } | 379 | } | ||
A internal/application/port/updater.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | // Package port defines interfaces for external dependencies. | |
| 2 | + | package port | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // UpdateChecker checks for available updates from a remote source. | |
| 11 | + | type UpdateChecker interface { | |
| 12 | + | // CheckForUpdate compares the current version with the latest available release. | |
| 13 | + | // Returns update info including whether a newer version is available. | |
| 14 | + | CheckForUpdate(ctx context.Context, currentVersion string) (*entity.UpdateInfo, error) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // UpdateDownloader downloads and extracts update archives. | |
| 18 | + | type UpdateDownloader interface { | |
| 19 | + | // Download fetches the update archive to the specified destination directory. | |
| 20 | + | // Returns the path to the downloaded archive file. | |
| 21 | + | Download(ctx context.Context, downloadURL string, destDir string) (archivePath string, err error) | |
| 22 | + | ||
| 23 | + | // Extract extracts the binary from the downloaded archive. | |
| 24 | + | // Returns the path to the extracted binary. | |
| 25 | + | Extract(ctx context.Context, archivePath string, destDir string) (binaryPath string, err error) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // UpdateApplier stages and applies updates. | |
| 29 | + | type UpdateApplier interface { | |
| 30 | + | // CanSelfUpdate checks if the current binary is writable by the current user. | |
| 31 | + | // Returns true if auto-update is possible, false if user lacks write permission. | |
| 32 | + | CanSelfUpdate(ctx context.Context) bool | |
| 33 | + | ||
| 34 | + | // GetBinaryPath returns the path to the currently running binary. | |
| 35 | + | GetBinaryPath() (string, error) | |
| 36 | + | ||
| 37 | + | // StageUpdate prepares the new binary to be applied on exit. | |
| 38 | + | // The staged update will be applied when ApplyOnExit is called. | |
| 39 | + | StageUpdate(ctx context.Context, newBinaryPath string) error | |
| 40 | + | ||
| 41 | + | // HasStagedUpdate checks if there is an update staged and ready to apply. | |
| 42 | + | HasStagedUpdate(ctx context.Context) bool | |
| 43 | + | ||
| 44 | + | // ApplyOnExit replaces the current binary with the staged update. | |
| 45 | + | // This should be called during graceful shutdown. | |
| 46 | + | // Returns the path to the backup of the old binary. | |
| 47 | + | ApplyOnExit(ctx context.Context) (backupPath string, err error) | |
| 48 | + | ||
| 49 | + | // ClearStagedUpdate removes any staged update without applying it. | |
| 50 | + | ClearStagedUpdate(ctx context.Context) error | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | // Package port defines interfaces for external dependencies. | |||
| 2 | + | package port | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // UpdateChecker checks for available updates from a remote source. | |||
| 11 | + | type UpdateChecker interface { | |||
| 12 | + | // CheckForUpdate compares the current version with the latest available release. | |||
| 13 | + | // Returns update info including whether a newer version is available. | |||
| 14 | + | CheckForUpdate(ctx context.Context, currentVersion string) (*entity.UpdateInfo, error) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // UpdateDownloader downloads and extracts update archives. | |||
| 18 | + | type UpdateDownloader interface { | |||
| 19 | + | // Download fetches the update archive to the specified destination directory. | |||
| 20 | + | // Returns the path to the downloaded archive file. | |||
| 21 | + | Download(ctx context.Context, downloadURL string, destDir string) (archivePath string, err error) | |||
| 22 | + | ||||
| 23 | + | // Extract extracts the binary from the downloaded archive. | |||
| 24 | + | // Returns the path to the extracted binary. | |||
| 25 | + | Extract(ctx context.Context, archivePath string, destDir string) (binaryPath string, err error) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // UpdateApplier stages and applies updates. | |||
| 29 | + | type UpdateApplier interface { | |||
| 30 | + | // CanSelfUpdate checks if the current binary is writable by the current user. | |||
| 31 | + | // Returns true if auto-update is possible, false if user lacks write permission. | |||
| 32 | + | CanSelfUpdate(ctx context.Context) bool | |||
| 33 | + | ||||
| 34 | + | // GetBinaryPath returns the path to the currently running binary. | |||
| 35 | + | GetBinaryPath() (string, error) | |||
| 36 | + | ||||
| 37 | + | // StageUpdate prepares the new binary to be applied on exit. | |||
| 38 | + | // The staged update will be applied when ApplyOnExit is called. | |||
| 39 | + | StageUpdate(ctx context.Context, newBinaryPath string) error | |||
| 40 | + | ||||
| 41 | + | // HasStagedUpdate checks if there is an update staged and ready to apply. | |||
| 42 | + | HasStagedUpdate(ctx context.Context) bool | |||
| 43 | + | ||||
| 44 | + | // ApplyOnExit replaces the current binary with the staged update. | |||
| 45 | + | // This should be called during graceful shutdown. | |||
| 46 | + | // Returns the path to the backup of the old binary. | |||
| 47 | + | ApplyOnExit(ctx context.Context) (backupPath string, err error) | |||
| 48 | + | ||||
| 49 | + | // ClearStagedUpdate removes any staged update without applying it. | |||
| 50 | + | ClearStagedUpdate(ctx context.Context) error | |||
| 51 | + | } | |||
A internal/application/usecase/apply_update.go +150 -0
| @@ -0,0 +1,150 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | // File permission for directories. | |
| 15 | + | updateDirPerm = 0o755 | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // ApplyUpdateInput holds the input for the apply update use case. | |
| 19 | + | type ApplyUpdateInput struct { | |
| 20 | + | // DownloadURL is the URL to download the update from. | |
| 21 | + | DownloadURL string | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // ApplyUpdateOutput holds the result of the update application. | |
| 25 | + | type ApplyUpdateOutput struct { | |
| 26 | + | // Status is the current update status. | |
| 27 | + | Status entity.UpdateStatus | |
| 28 | + | // Message is a human-readable status message. | |
| 29 | + | Message string | |
| 30 | + | // StagedPath is the path to the staged binary (if status is Ready). | |
| 31 | + | StagedPath string | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // ApplyUpdateUseCase downloads and stages updates for application on exit. | |
| 35 | + | type ApplyUpdateUseCase struct { | |
| 36 | + | downloader port.UpdateDownloader | |
| 37 | + | applier port.UpdateApplier | |
| 38 | + | cacheDir string | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // NewApplyUpdateUseCase creates a new apply update use case. | |
| 42 | + | func NewApplyUpdateUseCase( | |
| 43 | + | downloader port.UpdateDownloader, | |
| 44 | + | applier port.UpdateApplier, | |
| 45 | + | cacheDir string, | |
| 46 | + | ) *ApplyUpdateUseCase { | |
| 47 | + | return &ApplyUpdateUseCase{ | |
| 48 | + | downloader: downloader, | |
| 49 | + | applier: applier, | |
| 50 | + | cacheDir: cacheDir, | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Execute downloads and stages the update for application on exit. | |
| 55 | + | func (uc *ApplyUpdateUseCase) Execute(ctx context.Context, input ApplyUpdateInput) (*ApplyUpdateOutput, error) { | |
| 56 | + | log := logging.FromContext(ctx) | |
| 57 | + | ||
| 58 | + | // Check if we can self-update. | |
| 59 | + | if !uc.applier.CanSelfUpdate(ctx) { | |
| 60 | + | return &ApplyUpdateOutput{ | |
| 61 | + | Status: entity.UpdateStatusFailed, | |
| 62 | + | Message: "Cannot auto-update: binary is not writable", | |
| 63 | + | }, nil | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // Create download directory. | |
| 67 | + | downloadDir := uc.cacheDir + "/updates" | |
| 68 | + | if err := os.MkdirAll(downloadDir, updateDirPerm); err != nil { | |
| 69 | + | return nil, fmt.Errorf("failed to create download directory: %w", err) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | log.Info().Str("url", input.DownloadURL).Msg("downloading update") | |
| 73 | + | ||
| 74 | + | // Download the archive. | |
| 75 | + | archivePath, err := uc.downloader.Download(ctx, input.DownloadURL, downloadDir) | |
| 76 | + | if err != nil { | |
| 77 | + | return &ApplyUpdateOutput{ | |
| 78 | + | Status: entity.UpdateStatusFailed, | |
| 79 | + | Message: fmt.Sprintf("Download failed: %v", err), | |
| 80 | + | }, nil | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | log.Info().Str("archive", archivePath).Msg("extracting update") | |
| 84 | + | ||
| 85 | + | // Extract the binary. | |
| 86 | + | binaryPath, err := uc.downloader.Extract(ctx, archivePath, downloadDir) | |
| 87 | + | if err != nil { | |
| 88 | + | _ = os.Remove(archivePath) | |
| 89 | + | return &ApplyUpdateOutput{ | |
| 90 | + | Status: entity.UpdateStatusFailed, | |
| 91 | + | Message: fmt.Sprintf("Extraction failed: %v", err), | |
| 92 | + | }, nil | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | log.Info().Str("binary", binaryPath).Msg("staging update") | |
| 96 | + | ||
| 97 | + | // Stage the update. | |
| 98 | + | if err := uc.applier.StageUpdate(ctx, binaryPath); err != nil { | |
| 99 | + | _ = os.Remove(archivePath) | |
| 100 | + | _ = os.Remove(binaryPath) | |
| 101 | + | return &ApplyUpdateOutput{ | |
| 102 | + | Status: entity.UpdateStatusFailed, | |
| 103 | + | Message: fmt.Sprintf("Staging failed: %v", err), | |
| 104 | + | }, nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // Clean up download artifacts. | |
| 108 | + | _ = os.Remove(archivePath) | |
| 109 | + | _ = os.Remove(binaryPath) | |
| 110 | + | ||
| 111 | + | log.Info().Msg("update staged successfully, will apply on exit") | |
| 112 | + | ||
| 113 | + | return &ApplyUpdateOutput{ | |
| 114 | + | Status: entity.UpdateStatusReady, | |
| 115 | + | Message: "Update ready - will apply on exit", | |
| 116 | + | }, nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // FinalizeOnExit applies the staged update during shutdown. | |
| 120 | + | // This should be called from the app's shutdown handler. | |
| 121 | + | func (uc *ApplyUpdateUseCase) FinalizeOnExit(ctx context.Context) error { | |
| 122 | + | log := logging.FromContext(ctx) | |
| 123 | + | ||
| 124 | + | if !uc.applier.HasStagedUpdate(ctx) { | |
| 125 | + | log.Debug().Msg("no staged update to apply") | |
| 126 | + | return nil | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | backupPath, err := uc.applier.ApplyOnExit(ctx) | |
| 130 | + | if err != nil { | |
| 131 | + | log.Error().Err(err).Msg("failed to apply update on exit") | |
| 132 | + | return err | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | log.Info(). | |
| 136 | + | Str("backup", backupPath). | |
| 137 | + | Msg("update applied successfully") | |
| 138 | + | ||
| 139 | + | return nil | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // HasPendingUpdate checks if there's an update waiting to be applied on exit. | |
| 143 | + | func (uc *ApplyUpdateUseCase) HasPendingUpdate(ctx context.Context) bool { | |
| 144 | + | return uc.applier.HasStagedUpdate(ctx) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // ClearPendingUpdate removes any staged update. | |
| 148 | + | func (uc *ApplyUpdateUseCase) ClearPendingUpdate(ctx context.Context) error { | |
| 149 | + | return uc.applier.ClearStagedUpdate(ctx) | |
| 150 | + | } | |
| @@ -0,0 +1,150 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | + | "github.com/bnema/dumber/internal/logging" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | // File permission for directories. | |||
| 15 | + | updateDirPerm = 0o755 | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // ApplyUpdateInput holds the input for the apply update use case. | |||
| 19 | + | type ApplyUpdateInput struct { | |||
| 20 | + | // DownloadURL is the URL to download the update from. | |||
| 21 | + | DownloadURL string | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // ApplyUpdateOutput holds the result of the update application. | |||
| 25 | + | type ApplyUpdateOutput struct { | |||
| 26 | + | // Status is the current update status. | |||
| 27 | + | Status entity.UpdateStatus | |||
| 28 | + | // Message is a human-readable status message. | |||
| 29 | + | Message string | |||
| 30 | + | // StagedPath is the path to the staged binary (if status is Ready). | |||
| 31 | + | StagedPath string | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // ApplyUpdateUseCase downloads and stages updates for application on exit. | |||
| 35 | + | type ApplyUpdateUseCase struct { | |||
| 36 | + | downloader port.UpdateDownloader | |||
| 37 | + | applier port.UpdateApplier | |||
| 38 | + | cacheDir string | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // NewApplyUpdateUseCase creates a new apply update use case. | |||
| 42 | + | func NewApplyUpdateUseCase( | |||
| 43 | + | downloader port.UpdateDownloader, | |||
| 44 | + | applier port.UpdateApplier, | |||
| 45 | + | cacheDir string, | |||
| 46 | + | ) *ApplyUpdateUseCase { | |||
| 47 | + | return &ApplyUpdateUseCase{ | |||
| 48 | + | downloader: downloader, | |||
| 49 | + | applier: applier, | |||
| 50 | + | cacheDir: cacheDir, | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // Execute downloads and stages the update for application on exit. | |||
| 55 | + | func (uc *ApplyUpdateUseCase) Execute(ctx context.Context, input ApplyUpdateInput) (*ApplyUpdateOutput, error) { | |||
| 56 | + | log := logging.FromContext(ctx) | |||
| 57 | + | ||||
| 58 | + | // Check if we can self-update. | |||
| 59 | + | if !uc.applier.CanSelfUpdate(ctx) { | |||
| 60 | + | return &ApplyUpdateOutput{ | |||
| 61 | + | Status: entity.UpdateStatusFailed, | |||
| 62 | + | Message: "Cannot auto-update: binary is not writable", | |||
| 63 | + | }, nil | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // Create download directory. | |||
| 67 | + | downloadDir := uc.cacheDir + "/updates" | |||
| 68 | + | if err := os.MkdirAll(downloadDir, updateDirPerm); err != nil { | |||
| 69 | + | return nil, fmt.Errorf("failed to create download directory: %w", err) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | log.Info().Str("url", input.DownloadURL).Msg("downloading update") | |||
| 73 | + | ||||
| 74 | + | // Download the archive. | |||
| 75 | + | archivePath, err := uc.downloader.Download(ctx, input.DownloadURL, downloadDir) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return &ApplyUpdateOutput{ | |||
| 78 | + | Status: entity.UpdateStatusFailed, | |||
| 79 | + | Message: fmt.Sprintf("Download failed: %v", err), | |||
| 80 | + | }, nil | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | log.Info().Str("archive", archivePath).Msg("extracting update") | |||
| 84 | + | ||||
| 85 | + | // Extract the binary. | |||
| 86 | + | binaryPath, err := uc.downloader.Extract(ctx, archivePath, downloadDir) | |||
| 87 | + | if err != nil { | |||
| 88 | + | _ = os.Remove(archivePath) | |||
| 89 | + | return &ApplyUpdateOutput{ | |||
| 90 | + | Status: entity.UpdateStatusFailed, | |||
| 91 | + | Message: fmt.Sprintf("Extraction failed: %v", err), | |||
| 92 | + | }, nil | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | log.Info().Str("binary", binaryPath).Msg("staging update") | |||
| 96 | + | ||||
| 97 | + | // Stage the update. | |||
| 98 | + | if err := uc.applier.StageUpdate(ctx, binaryPath); err != nil { | |||
| 99 | + | _ = os.Remove(archivePath) | |||
| 100 | + | _ = os.Remove(binaryPath) | |||
| 101 | + | return &ApplyUpdateOutput{ | |||
| 102 | + | Status: entity.UpdateStatusFailed, | |||
| 103 | + | Message: fmt.Sprintf("Staging failed: %v", err), | |||
| 104 | + | }, nil | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Clean up download artifacts. | |||
| 108 | + | _ = os.Remove(archivePath) | |||
| 109 | + | _ = os.Remove(binaryPath) | |||
| 110 | + | ||||
| 111 | + | log.Info().Msg("update staged successfully, will apply on exit") | |||
| 112 | + | ||||
| 113 | + | return &ApplyUpdateOutput{ | |||
| 114 | + | Status: entity.UpdateStatusReady, | |||
| 115 | + | Message: "Update ready - will apply on exit", | |||
| 116 | + | }, nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // FinalizeOnExit applies the staged update during shutdown. | |||
| 120 | + | // This should be called from the app's shutdown handler. | |||
| 121 | + | func (uc *ApplyUpdateUseCase) FinalizeOnExit(ctx context.Context) error { | |||
| 122 | + | log := logging.FromContext(ctx) | |||
| 123 | + | ||||
| 124 | + | if !uc.applier.HasStagedUpdate(ctx) { | |||
| 125 | + | log.Debug().Msg("no staged update to apply") | |||
| 126 | + | return nil | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | backupPath, err := uc.applier.ApplyOnExit(ctx) | |||
| 130 | + | if err != nil { | |||
| 131 | + | log.Error().Err(err).Msg("failed to apply update on exit") | |||
| 132 | + | return err | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | log.Info(). | |||
| 136 | + | Str("backup", backupPath). | |||
| 137 | + | Msg("update applied successfully") | |||
| 138 | + | ||||
| 139 | + | return nil | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // HasPendingUpdate checks if there's an update waiting to be applied on exit. | |||
| 143 | + | func (uc *ApplyUpdateUseCase) HasPendingUpdate(ctx context.Context) bool { | |||
| 144 | + | return uc.applier.HasStagedUpdate(ctx) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // ClearPendingUpdate removes any staged update. | |||
| 148 | + | func (uc *ApplyUpdateUseCase) ClearPendingUpdate(ctx context.Context) error { | |||
| 149 | + | return uc.applier.ClearStagedUpdate(ctx) | |||
| 150 | + | } | |||
A internal/application/usecase/check_update.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/bnema/dumber/internal/domain/build" | |
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // CheckUpdateInput holds the input for the check update use case. | |
| 12 | + | type CheckUpdateInput struct{} | |
| 13 | + | ||
| 14 | + | // CheckUpdateOutput holds the result of the update check. | |
| 15 | + | type CheckUpdateOutput struct { | |
| 16 | + | // UpdateAvailable is true if a newer version exists. | |
| 17 | + | UpdateAvailable bool | |
| 18 | + | // CanAutoUpdate is true if the binary is writable and auto-update is possible. | |
| 19 | + | CanAutoUpdate bool | |
| 20 | + | // CurrentVersion is the version of the running binary. | |
| 21 | + | CurrentVersion string | |
| 22 | + | // LatestVersion is the latest available version. | |
| 23 | + | LatestVersion string | |
| 24 | + | // ReleaseURL is the URL to the GitHub release page. | |
| 25 | + | ReleaseURL string | |
| 26 | + | // DownloadURL is the direct download URL for the archive. | |
| 27 | + | DownloadURL string | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // CheckUpdateUseCase checks for available updates. | |
| 31 | + | type CheckUpdateUseCase struct { | |
| 32 | + | checker port.UpdateChecker | |
| 33 | + | applier port.UpdateApplier | |
| 34 | + | buildInfo build.Info | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // NewCheckUpdateUseCase creates a new check update use case. | |
| 38 | + | func NewCheckUpdateUseCase( | |
| 39 | + | checker port.UpdateChecker, | |
| 40 | + | applier port.UpdateApplier, | |
| 41 | + | buildInfo build.Info, | |
| 42 | + | ) *CheckUpdateUseCase { | |
| 43 | + | return &CheckUpdateUseCase{ | |
| 44 | + | checker: checker, | |
| 45 | + | applier: applier, | |
| 46 | + | buildInfo: buildInfo, | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Execute checks for available updates. | |
| 51 | + | func (uc *CheckUpdateUseCase) Execute(ctx context.Context, _ CheckUpdateInput) (*CheckUpdateOutput, error) { | |
| 52 | + | log := logging.FromContext(ctx) | |
| 53 | + | ||
| 54 | + | info, err := uc.checker.CheckForUpdate(ctx, uc.buildInfo.Version) | |
| 55 | + | if err != nil { | |
| 56 | + | log.Warn().Err(err).Msg("update check failed") | |
| 57 | + | return nil, err | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | canAutoUpdate := false | |
| 61 | + | if info.IsNewer { | |
| 62 | + | canAutoUpdate = uc.applier.CanSelfUpdate(ctx) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | log.Debug(). | |
| 66 | + | Str("current", uc.buildInfo.Version). | |
| 67 | + | Str("latest", info.LatestVersion). | |
| 68 | + | Bool("update_available", info.IsNewer). | |
| 69 | + | Bool("can_auto_update", canAutoUpdate). | |
| 70 | + | Msg("update check completed") | |
| 71 | + | ||
| 72 | + | return &CheckUpdateOutput{ | |
| 73 | + | UpdateAvailable: info.IsNewer, | |
| 74 | + | CanAutoUpdate: canAutoUpdate, | |
| 75 | + | CurrentVersion: info.CurrentVersion, | |
| 76 | + | LatestVersion: info.LatestVersion, | |
| 77 | + | ReleaseURL: info.ReleaseURL, | |
| 78 | + | DownloadURL: info.DownloadURL, | |
| 79 | + | }, nil | |
| 80 | + | } | |
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | "github.com/bnema/dumber/internal/domain/build" | |||
| 8 | + | "github.com/bnema/dumber/internal/logging" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // CheckUpdateInput holds the input for the check update use case. | |||
| 12 | + | type CheckUpdateInput struct{} | |||
| 13 | + | ||||
| 14 | + | // CheckUpdateOutput holds the result of the update check. | |||
| 15 | + | type CheckUpdateOutput struct { | |||
| 16 | + | // UpdateAvailable is true if a newer version exists. | |||
| 17 | + | UpdateAvailable bool | |||
| 18 | + | // CanAutoUpdate is true if the binary is writable and auto-update is possible. | |||
| 19 | + | CanAutoUpdate bool | |||
| 20 | + | // CurrentVersion is the version of the running binary. | |||
| 21 | + | CurrentVersion string | |||
| 22 | + | // LatestVersion is the latest available version. | |||
| 23 | + | LatestVersion string | |||
| 24 | + | // ReleaseURL is the URL to the GitHub release page. | |||
| 25 | + | ReleaseURL string | |||
| 26 | + | // DownloadURL is the direct download URL for the archive. | |||
| 27 | + | DownloadURL string | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // CheckUpdateUseCase checks for available updates. | |||
| 31 | + | type CheckUpdateUseCase struct { | |||
| 32 | + | checker port.UpdateChecker | |||
| 33 | + | applier port.UpdateApplier | |||
| 34 | + | buildInfo build.Info | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // NewCheckUpdateUseCase creates a new check update use case. | |||
| 38 | + | func NewCheckUpdateUseCase( | |||
| 39 | + | checker port.UpdateChecker, | |||
| 40 | + | applier port.UpdateApplier, | |||
| 41 | + | buildInfo build.Info, | |||
| 42 | + | ) *CheckUpdateUseCase { | |||
| 43 | + | return &CheckUpdateUseCase{ | |||
| 44 | + | checker: checker, | |||
| 45 | + | applier: applier, | |||
| 46 | + | buildInfo: buildInfo, | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Execute checks for available updates. | |||
| 51 | + | func (uc *CheckUpdateUseCase) Execute(ctx context.Context, _ CheckUpdateInput) (*CheckUpdateOutput, error) { | |||
| 52 | + | log := logging.FromContext(ctx) | |||
| 53 | + | ||||
| 54 | + | info, err := uc.checker.CheckForUpdate(ctx, uc.buildInfo.Version) | |||
| 55 | + | if err != nil { | |||
| 56 | + | log.Warn().Err(err).Msg("update check failed") | |||
| 57 | + | return nil, err | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | canAutoUpdate := false | |||
| 61 | + | if info.IsNewer { | |||
| 62 | + | canAutoUpdate = uc.applier.CanSelfUpdate(ctx) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | log.Debug(). | |||
| 66 | + | Str("current", uc.buildInfo.Version). | |||
| 67 | + | Str("latest", info.LatestVersion). | |||
| 68 | + | Bool("update_available", info.IsNewer). | |||
| 69 | + | Bool("can_auto_update", canAutoUpdate). | |||
| 70 | + | Msg("update check completed") | |||
| 71 | + | ||||
| 72 | + | return &CheckUpdateOutput{ | |||
| 73 | + | UpdateAvailable: info.IsNewer, | |||
| 74 | + | CanAutoUpdate: canAutoUpdate, | |||
| 75 | + | CurrentVersion: info.CurrentVersion, | |||
| 76 | + | LatestVersion: info.LatestVersion, | |||
| 77 | + | ReleaseURL: info.ReleaseURL, | |||
| 78 | + | DownloadURL: info.DownloadURL, | |||
| 79 | + | }, nil | |||
| 80 | + | } | |||
M internal/cli/cmd/about.go +37 -0
| @@ -1,11 +1,16 @@ | |||
| 1 | 1 | package cmd | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "fmt" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/spf13/cobra" | |
| 7 | 8 | ||
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/cli" | |
| 8 | 11 | "github.com/bnema/dumber/internal/cli/styles" | |
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/updater" | |
| 13 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | 14 | ) | |
| 10 | 15 | ||
| 11 | 16 | var aboutCmd = &cobra.Command{ | |
| @@ -29,5 +34,37 @@ func runAbout(_ *cobra.Command, _ []string) error { | |||
| 29 | 34 | output := renderer.Render(app.BuildInfo) | |
| 30 | 35 | ||
| 31 | 36 | fmt.Println(output) | |
| 37 | + | ||
| 38 | + | // Check for updates | |
| 39 | + | if err := checkForUpdates(app); err != nil { | |
| 40 | + | // Log but don't fail - update check is optional | |
| 41 | + | ctx := logging.WithContext(context.Background(), logging.NewFromConfigValues("warn", "text")) | |
| 42 | + | log := logging.FromContext(ctx) | |
| 43 | + | log.Debug().Err(err).Msg("update check failed") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | return nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func checkForUpdates(app *cli.App) error { | |
| 50 | + | ctx := context.Background() | |
| 51 | + | ||
| 52 | + | // Create update checker | |
| 53 | + | checker := updater.NewGitHubChecker() | |
| 54 | + | applier := updater.NewApplier("") | |
| 55 | + | ||
| 56 | + | checkUC := usecase.NewCheckUpdateUseCase(checker, applier, app.BuildInfo) | |
| 57 | + | ||
| 58 | + | result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{}) | |
| 59 | + | if err != nil { | |
| 60 | + | return err | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | if result.UpdateAvailable { | |
| 64 | + | fmt.Println() | |
| 65 | + | fmt.Printf(" ✨ Update available: %s → %s\n", result.CurrentVersion, result.LatestVersion) | |
| 66 | + | fmt.Printf(" %s\n", result.ReleaseURL) | |
| 67 | + | } | |
| 68 | + | ||
| 32 | 69 | return nil | |
| 33 | 70 | } | |
| @@ -1,11 +1,16 @@ | |||||
| 1 | package cmd | 1 | package cmd | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "fmt" | 5 | "fmt" | ||
| 5 | 6 | ||||
| 6 | "github.com/spf13/cobra" | 7 | "github.com/spf13/cobra" | ||
| 7 | 8 | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 10 | + | "github.com/bnema/dumber/internal/cli" | |||
| 8 | "github.com/bnema/dumber/internal/cli/styles" | 11 | "github.com/bnema/dumber/internal/cli/styles" | ||
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/updater" | |||
| 13 | + | "github.com/bnema/dumber/internal/logging" | |||
| 9 | ) | 14 | ) | ||
| 10 | 15 | ||||
| 11 | var aboutCmd = &cobra.Command{ | 16 | var aboutCmd = &cobra.Command{ | ||
| @@ -29,5 +34,37 @@ func runAbout(_ *cobra.Command, _ []string) error { | |||||
| 29 | output := renderer.Render(app.BuildInfo) | 34 | output := renderer.Render(app.BuildInfo) | ||
| 30 | 35 | ||||
| 31 | fmt.Println(output) | 36 | fmt.Println(output) | ||
| 37 | + | ||||
| 38 | + | // Check for updates | |||
| 39 | + | if err := checkForUpdates(app); err != nil { | |||
| 40 | + | // Log but don't fail - update check is optional | |||
| 41 | + | ctx := logging.WithContext(context.Background(), logging.NewFromConfigValues("warn", "text")) | |||
| 42 | + | log := logging.FromContext(ctx) | |||
| 43 | + | log.Debug().Err(err).Msg("update check failed") | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | return nil | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func checkForUpdates(app *cli.App) error { | |||
| 50 | + | ctx := context.Background() | |||
| 51 | + | ||||
| 52 | + | // Create update checker | |||
| 53 | + | checker := updater.NewGitHubChecker() | |||
| 54 | + | applier := updater.NewApplier("") | |||
| 55 | + | ||||
| 56 | + | checkUC := usecase.NewCheckUpdateUseCase(checker, applier, app.BuildInfo) | |||
| 57 | + | ||||
| 58 | + | result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{}) | |||
| 59 | + | if err != nil { | |||
| 60 | + | return err | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | if result.UpdateAvailable { | |||
| 64 | + | fmt.Println() | |||
| 65 | + | fmt.Printf(" ✨ Update available: %s → %s\n", result.CurrentVersion, result.LatestVersion) | |||
| 66 | + | fmt.Printf(" %s\n", result.ReleaseURL) | |||
| 67 | + | } | |||
| 68 | + | ||||
| 32 | return nil | 69 | return nil | ||
| 33 | } | 70 | } | ||
A internal/domain/entity/update.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | // Package entity defines domain entities for dumber. | |
| 2 | + | package entity | |
| 3 | + | ||
| 4 | + | import "time" | |
| 5 | + | ||
| 6 | + | // UpdateInfo holds information about an available update. | |
| 7 | + | type UpdateInfo struct { | |
| 8 | + | // CurrentVersion is the version of the running binary. | |
| 9 | + | CurrentVersion string | |
| 10 | + | // LatestVersion is the latest available version from GitHub. | |
| 11 | + | LatestVersion string | |
| 12 | + | // IsNewer is true if LatestVersion is newer than CurrentVersion. | |
| 13 | + | IsNewer bool | |
| 14 | + | // ReleaseURL is the URL to the GitHub release page. | |
| 15 | + | ReleaseURL string | |
| 16 | + | // DownloadURL is the direct download URL for the binary archive. | |
| 17 | + | DownloadURL string | |
| 18 | + | // PublishedAt is when the release was published. | |
| 19 | + | PublishedAt time.Time | |
| 20 | + | // ReleaseNotes contains the release changelog (optional). | |
| 21 | + | ReleaseNotes string | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // UpdateStatus represents the current state of the update process. | |
| 25 | + | type UpdateStatus int | |
| 26 | + | ||
| 27 | + | const ( | |
| 28 | + | // UpdateStatusUnknown means update status hasn't been checked yet. | |
| 29 | + | UpdateStatusUnknown UpdateStatus = iota | |
| 30 | + | // UpdateStatusUpToDate means the current version is the latest. | |
| 31 | + | UpdateStatusUpToDate | |
| 32 | + | // UpdateStatusAvailable means a newer version is available. | |
| 33 | + | UpdateStatusAvailable | |
| 34 | + | // UpdateStatusDownloading means the update is being downloaded. | |
| 35 | + | UpdateStatusDownloading | |
| 36 | + | // UpdateStatusReady means the update is downloaded and staged for exit. | |
| 37 | + | UpdateStatusReady | |
| 38 | + | // UpdateStatusFailed means the update check or download failed. | |
| 39 | + | UpdateStatusFailed | |
| 40 | + | ) | |
| 41 | + | ||
| 42 | + | // String returns a human-readable string for the update status. | |
| 43 | + | func (s UpdateStatus) String() string { | |
| 44 | + | switch s { | |
| 45 | + | case UpdateStatusUnknown: | |
| 46 | + | return "unknown" | |
| 47 | + | case UpdateStatusUpToDate: | |
| 48 | + | return "up-to-date" | |
| 49 | + | case UpdateStatusAvailable: | |
| 50 | + | return "available" | |
| 51 | + | case UpdateStatusDownloading: | |
| 52 | + | return "downloading" | |
| 53 | + | case UpdateStatusReady: | |
| 54 | + | return "ready" | |
| 55 | + | case UpdateStatusFailed: | |
| 56 | + | return "failed" | |
| 57 | + | default: | |
| 58 | + | return "unknown" | |
| 59 | + | } | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | // Package entity defines domain entities for dumber. | |||
| 2 | + | package entity | |||
| 3 | + | ||||
| 4 | + | import "time" | |||
| 5 | + | ||||
| 6 | + | // UpdateInfo holds information about an available update. | |||
| 7 | + | type UpdateInfo struct { | |||
| 8 | + | // CurrentVersion is the version of the running binary. | |||
| 9 | + | CurrentVersion string | |||
| 10 | + | // LatestVersion is the latest available version from GitHub. | |||
| 11 | + | LatestVersion string | |||
| 12 | + | // IsNewer is true if LatestVersion is newer than CurrentVersion. | |||
| 13 | + | IsNewer bool | |||
| 14 | + | // ReleaseURL is the URL to the GitHub release page. | |||
| 15 | + | ReleaseURL string | |||
| 16 | + | // DownloadURL is the direct download URL for the binary archive. | |||
| 17 | + | DownloadURL string | |||
| 18 | + | // PublishedAt is when the release was published. | |||
| 19 | + | PublishedAt time.Time | |||
| 20 | + | // ReleaseNotes contains the release changelog (optional). | |||
| 21 | + | ReleaseNotes string | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // UpdateStatus represents the current state of the update process. | |||
| 25 | + | type UpdateStatus int | |||
| 26 | + | ||||
| 27 | + | const ( | |||
| 28 | + | // UpdateStatusUnknown means update status hasn't been checked yet. | |||
| 29 | + | UpdateStatusUnknown UpdateStatus = iota | |||
| 30 | + | // UpdateStatusUpToDate means the current version is the latest. | |||
| 31 | + | UpdateStatusUpToDate | |||
| 32 | + | // UpdateStatusAvailable means a newer version is available. | |||
| 33 | + | UpdateStatusAvailable | |||
| 34 | + | // UpdateStatusDownloading means the update is being downloaded. | |||
| 35 | + | UpdateStatusDownloading | |||
| 36 | + | // UpdateStatusReady means the update is downloaded and staged for exit. | |||
| 37 | + | UpdateStatusReady | |||
| 38 | + | // UpdateStatusFailed means the update check or download failed. | |||
| 39 | + | UpdateStatusFailed | |||
| 40 | + | ) | |||
| 41 | + | ||||
| 42 | + | // String returns a human-readable string for the update status. | |||
| 43 | + | func (s UpdateStatus) String() string { | |||
| 44 | + | switch s { | |||
| 45 | + | case UpdateStatusUnknown: | |||
| 46 | + | return "unknown" | |||
| 47 | + | case UpdateStatusUpToDate: | |||
| 48 | + | return "up-to-date" | |||
| 49 | + | case UpdateStatusAvailable: | |||
| 50 | + | return "available" | |||
| 51 | + | case UpdateStatusDownloading: | |||
| 52 | + | return "downloading" | |||
| 53 | + | case UpdateStatusReady: | |||
| 54 | + | return "ready" | |||
| 55 | + | case UpdateStatusFailed: | |||
| 56 | + | return "failed" | |||
| 57 | + | default: | |||
| 58 | + | return "unknown" | |||
| 59 | + | } | |||
| 60 | + | } | |||
M internal/infrastructure/config/defaults.go +4 -0
| @@ -241,6 +241,10 @@ func DefaultConfig() *Config { | |||
| 241 | 241 | ZoomCacheSize: defaultZoomCacheSize, | |
| 242 | 242 | WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount, | |
| 243 | 243 | }, | |
| 244 | + | Update: UpdateConfig{ | |
| 245 | + | EnableOnStartup: true, // Check for updates on startup by default | |
| 246 | + | AutoDownload: false, // Conservative: don't auto-download by default | |
| 247 | + | }, | |
| 244 | 248 | } | |
| 245 | 249 | } | |
| 246 | 250 | ||
| @@ -241,6 +241,10 @@ func DefaultConfig() *Config { | |||||
| 241 | ZoomCacheSize: defaultZoomCacheSize, | 241 | ZoomCacheSize: defaultZoomCacheSize, | ||
| 242 | WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount, | 242 | WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount, | ||
| 243 | }, | 243 | }, | ||
| 244 | + | Update: UpdateConfig{ | |||
| 245 | + | EnableOnStartup: true, // Check for updates on startup by default | |||
| 246 | + | AutoDownload: false, // Conservative: don't auto-download by default | |||
| 247 | + | }, | |||
| 244 | } | 248 | } | ||
| 245 | } | 249 | } | ||
| 246 | 250 | ||||
M internal/infrastructure/config/loader.go +6 -0
| @@ -315,6 +315,7 @@ func (m *Manager) setDefaults() { | |||
| 315 | 315 | m.setMediaDefaults(defaults) | |
| 316 | 316 | m.setRuntimeDefaults(defaults) | |
| 317 | 317 | m.setSessionDefaults(defaults) | |
| 318 | + | m.setUpdateDefaults(defaults) | |
| 318 | 319 | } | |
| 319 | 320 | ||
| 320 | 321 | func (m *Manager) setHistoryDefaults(defaults *Config) { | |
| @@ -442,6 +443,11 @@ func (m *Manager) setSessionDefaults(defaults *Config) { | |||
| 442 | 443 | m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions) | |
| 443 | 444 | } | |
| 444 | 445 | ||
| 446 | + | func (m *Manager) setUpdateDefaults(defaults *Config) { | |
| 447 | + | m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup) | |
| 448 | + | m.viper.SetDefault("update.auto_download", defaults.Update.AutoDownload) | |
| 449 | + | } | |
| 450 | + | ||
| 445 | 451 | // New returns a new default configuration instance. | |
| 446 | 452 | // This is a convenience function for getting default config without the full manager. | |
| 447 | 453 | func New() *Config { | |
| @@ -315,6 +315,7 @@ func (m *Manager) setDefaults() { | |||||
| 315 | m.setMediaDefaults(defaults) | 315 | m.setMediaDefaults(defaults) | ||
| 316 | m.setRuntimeDefaults(defaults) | 316 | m.setRuntimeDefaults(defaults) | ||
| 317 | m.setSessionDefaults(defaults) | 317 | m.setSessionDefaults(defaults) | ||
| 318 | + | m.setUpdateDefaults(defaults) | |||
| 318 | } | 319 | } | ||
| 319 | 320 | ||||
| 320 | func (m *Manager) setHistoryDefaults(defaults *Config) { | 321 | func (m *Manager) setHistoryDefaults(defaults *Config) { | ||
| @@ -442,6 +443,11 @@ func (m *Manager) setSessionDefaults(defaults *Config) { | |||||
| 442 | m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions) | 443 | m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions) | ||
| 443 | } | 444 | } | ||
| 444 | 445 | ||||
| 446 | + | func (m *Manager) setUpdateDefaults(defaults *Config) { | |||
| 447 | + | m.viper.SetDefault("update.enable_on_startup", defaults.Update.EnableOnStartup) | |||
| 448 | + | m.viper.SetDefault("update.auto_download", defaults.Update.AutoDownload) | |||
| 449 | + | } | |||
| 450 | + | ||||
| 445 | // New returns a new default configuration instance. | 451 | // New returns a new default configuration instance. | ||
| 446 | // This is a convenience function for getting default config without the full manager. | 452 | // This is a convenience function for getting default config without the full manager. | ||
| 447 | func New() *Config { | 453 | func New() *Config { | ||
M internal/infrastructure/config/schema.go +13 -0
| @@ -31,6 +31,8 @@ type Config struct { | |||
| 31 | 31 | Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"` | |
| 32 | 32 | // Performance holds internal performance tuning options (not exposed in UI). | |
| 33 | 33 | Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"` | |
| 34 | + | // Update controls automatic update checking and downloading. | |
| 35 | + | Update UpdateConfig `mapstructure:"update" yaml:"update" toml:"update"` | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 36 | 38 | // RenderingMode selects GPU vs CPU rendering. | |
| @@ -280,6 +282,17 @@ type DebugConfig struct { | |||
| 280 | 282 | EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"` | |
| 281 | 283 | } | |
| 282 | 284 | ||
| 285 | + | // UpdateConfig holds automatic update settings. | |
| 286 | + | type UpdateConfig struct { | |
| 287 | + | // EnableOnStartup enables checking for updates when the browser starts. | |
| 288 | + | // Default: true | |
| 289 | + | EnableOnStartup bool `mapstructure:"enable_on_startup" yaml:"enable_on_startup" toml:"enable_on_startup"` | |
| 290 | + | // AutoDownload automatically downloads updates in the background. | |
| 291 | + | // When enabled, updates are applied on browser exit. | |
| 292 | + | // Default: false | |
| 293 | + | AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"` | |
| 294 | + | } | |
| 295 | + | ||
| 283 | 296 | // WorkspaceConfig captures layout, pane, and tab behavior preferences. | |
| 284 | 297 | type WorkspaceConfig struct { | |
| 285 | 298 | // PaneMode defines modal pane behavior and bindings. | |
| @@ -31,6 +31,8 @@ type Config struct { | |||||
| 31 | Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"` | 31 | Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"` | ||
| 32 | // Performance holds internal performance tuning options (not exposed in UI). | 32 | // Performance holds internal performance tuning options (not exposed in UI). | ||
| 33 | Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"` | 33 | Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"` | ||
| 34 | + | // Update controls automatic update checking and downloading. | |||
| 35 | + | Update UpdateConfig `mapstructure:"update" yaml:"update" toml:"update"` | |||
| 34 | } | 36 | } | ||
| 35 | 37 | ||||
| 36 | // RenderingMode selects GPU vs CPU rendering. | 38 | // RenderingMode selects GPU vs CPU rendering. | ||
| @@ -280,6 +282,17 @@ type DebugConfig struct { | |||||
| 280 | EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"` | 282 | EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"` | ||
| 281 | } | 283 | } | ||
| 282 | 284 | ||||
| 285 | + | // UpdateConfig holds automatic update settings. | |||
| 286 | + | type UpdateConfig struct { | |||
| 287 | + | // EnableOnStartup enables checking for updates when the browser starts. | |||
| 288 | + | // Default: true | |||
| 289 | + | EnableOnStartup bool `mapstructure:"enable_on_startup" yaml:"enable_on_startup" toml:"enable_on_startup"` | |||
| 290 | + | // AutoDownload automatically downloads updates in the background. | |||
| 291 | + | // When enabled, updates are applied on browser exit. | |||
| 292 | + | // Default: false | |||
| 293 | + | AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"` | |||
| 294 | + | } | |||
| 295 | + | ||||
| 283 | // WorkspaceConfig captures layout, pane, and tab behavior preferences. | 296 | // WorkspaceConfig captures layout, pane, and tab behavior preferences. | ||
| 284 | type WorkspaceConfig struct { | 297 | type WorkspaceConfig struct { | ||
| 285 | // PaneMode defines modal pane behavior and bindings. | 298 | // PaneMode defines modal pane behavior and bindings. | ||
A internal/infrastructure/updater/applier.go +203 -0
| @@ -0,0 +1,203 @@ | |||
| 1 | + | package updater | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | ||
| 9 | + | "golang.org/x/sys/unix" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | // Staging directory name within XDG_STATE_HOME. | |
| 17 | + | stagingDirName = "pending-update" | |
| 18 | + | // Staged binary filename. | |
| 19 | + | stagedBinaryName = "dumber" | |
| 20 | + | // Backup suffix for old binary. | |
| 21 | + | backupSuffix = ".old" | |
| 22 | + | // File permission for directories and executables. | |
| 23 | + | applierExecPerm = 0o755 | |
| 24 | + | // Execute permission bit for owner. | |
| 25 | + | execBit = 0o100 | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // Applier implements UpdateApplier for self-updating the binary. | |
| 29 | + | type Applier struct { | |
| 30 | + | stateDir string | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // NewApplier creates a new update applier. | |
| 34 | + | // stateDir is the XDG state directory (e.g., ~/.local/state/dumber). | |
| 35 | + | func NewApplier(stateDir string) *Applier { | |
| 36 | + | return &Applier{ | |
| 37 | + | stateDir: stateDir, | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // NewApplierFromXDG creates a new update applier using XDG directories. | |
| 42 | + | func NewApplierFromXDG() (*Applier, error) { | |
| 43 | + | stateDir, err := config.GetStateDir() | |
| 44 | + | if err != nil { | |
| 45 | + | return nil, fmt.Errorf("failed to get state directory: %w", err) | |
| 46 | + | } | |
| 47 | + | return NewApplier(stateDir), nil | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // stagingDir returns the path to the staging directory. | |
| 51 | + | func (a *Applier) stagingDir() string { | |
| 52 | + | return filepath.Join(a.stateDir, stagingDirName) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // stagedBinaryPath returns the path to the staged binary. | |
| 56 | + | func (a *Applier) stagedBinaryPath() string { | |
| 57 | + | return filepath.Join(a.stagingDir(), stagedBinaryName) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // CanSelfUpdate checks if the current binary is writable by the current user. | |
| 61 | + | func (a *Applier) CanSelfUpdate(ctx context.Context) bool { | |
| 62 | + | log := logging.FromContext(ctx) | |
| 63 | + | ||
| 64 | + | binaryPath, err := a.GetBinaryPath() | |
| 65 | + | if err != nil { | |
| 66 | + | log.Debug().Err(err).Msg("failed to get binary path") | |
| 67 | + | return false | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Check if we have write permission on the binary file. | |
| 71 | + | err = unix.Access(binaryPath, unix.W_OK) | |
| 72 | + | if err != nil { | |
| 73 | + | log.Debug(). | |
| 74 | + | Str("path", binaryPath). | |
| 75 | + | Err(err). | |
| 76 | + | Msg("binary is not writable, self-update disabled") | |
| 77 | + | return false | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | log.Debug().Str("path", binaryPath).Msg("binary is writable, self-update enabled") | |
| 81 | + | return true | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // GetBinaryPath returns the path to the currently running binary. | |
| 85 | + | func (*Applier) GetBinaryPath() (string, error) { | |
| 86 | + | path, err := os.Executable() | |
| 87 | + | if err != nil { | |
| 88 | + | return "", fmt.Errorf("failed to get executable path: %w", err) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Resolve any symlinks to get the actual binary path. | |
| 92 | + | resolved, err := filepath.EvalSymlinks(path) | |
| 93 | + | if err != nil { | |
| 94 | + | return "", fmt.Errorf("failed to resolve symlinks: %w", err) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | return resolved, nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // StageUpdate copies the new binary to the staging directory. | |
| 101 | + | func (a *Applier) StageUpdate(ctx context.Context, newBinaryPath string) error { | |
| 102 | + | log := logging.FromContext(ctx) | |
| 103 | + | ||
| 104 | + | // Create staging directory. | |
| 105 | + | stagingDir := a.stagingDir() | |
| 106 | + | if err := os.MkdirAll(stagingDir, applierExecPerm); err != nil { | |
| 107 | + | return fmt.Errorf("failed to create staging directory: %w", err) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Read the new binary. | |
| 111 | + | newBinary, err := os.ReadFile(newBinaryPath) | |
| 112 | + | if err != nil { | |
| 113 | + | return fmt.Errorf("failed to read new binary: %w", err) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Write to staging location. | |
| 117 | + | stagedPath := a.stagedBinaryPath() | |
| 118 | + | if err := os.WriteFile(stagedPath, newBinary, applierExecPerm); err != nil { | |
| 119 | + | return fmt.Errorf("failed to write staged binary: %w", err) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | log.Info(). | |
| 123 | + | Str("staged_path", stagedPath). | |
| 124 | + | Int("size", len(newBinary)). | |
| 125 | + | Msg("update staged for exit") | |
| 126 | + | ||
| 127 | + | return nil | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // HasStagedUpdate checks if there is an update staged and ready to apply. | |
| 131 | + | func (a *Applier) HasStagedUpdate(_ context.Context) bool { | |
| 132 | + | stagedPath := a.stagedBinaryPath() | |
| 133 | + | info, err := os.Stat(stagedPath) | |
| 134 | + | if err != nil { | |
| 135 | + | return false | |
| 136 | + | } | |
| 137 | + | // Ensure it's a regular file with execute permission. | |
| 138 | + | return info.Mode().IsRegular() && info.Mode()&execBit != 0 | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // ApplyOnExit replaces the current binary with the staged update. | |
| 142 | + | // This should be called during graceful shutdown. | |
| 143 | + | func (a *Applier) ApplyOnExit(ctx context.Context) (string, error) { | |
| 144 | + | log := logging.FromContext(ctx) | |
| 145 | + | ||
| 146 | + | if !a.HasStagedUpdate(ctx) { | |
| 147 | + | return "", fmt.Errorf("no staged update found") | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | binaryPath, err := a.GetBinaryPath() | |
| 151 | + | if err != nil { | |
| 152 | + | return "", err | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | stagedPath := a.stagedBinaryPath() | |
| 156 | + | backupPath := binaryPath + backupSuffix | |
| 157 | + | ||
| 158 | + | log.Info(). | |
| 159 | + | Str("current", binaryPath). | |
| 160 | + | Str("staged", stagedPath). | |
| 161 | + | Str("backup", backupPath). | |
| 162 | + | Msg("applying staged update") | |
| 163 | + | ||
| 164 | + | // Step 1: Remove old backup if exists. | |
| 165 | + | _ = os.Remove(backupPath) | |
| 166 | + | ||
| 167 | + | // Step 2: Rename current binary to backup. | |
| 168 | + | if err := os.Rename(binaryPath, backupPath); err != nil { | |
| 169 | + | return "", fmt.Errorf("failed to backup current binary: %w", err) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // Step 3: Rename staged binary to current. | |
| 173 | + | if err := os.Rename(stagedPath, binaryPath); err != nil { | |
| 174 | + | // Try to restore backup. | |
| 175 | + | if restoreErr := os.Rename(backupPath, binaryPath); restoreErr != nil { | |
| 176 | + | log.Error().Err(restoreErr).Msg("failed to restore backup after update failure") | |
| 177 | + | } | |
| 178 | + | return "", fmt.Errorf("failed to install new binary: %w", err) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // Step 4: Clean up staging directory. | |
| 182 | + | _ = os.RemoveAll(a.stagingDir()) | |
| 183 | + | ||
| 184 | + | log.Info(). | |
| 185 | + | Str("binary", binaryPath). | |
| 186 | + | Str("backup", backupPath). | |
| 187 | + | Msg("update applied successfully") | |
| 188 | + | ||
| 189 | + | return backupPath, nil | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // ClearStagedUpdate removes any staged update without applying it. | |
| 193 | + | func (a *Applier) ClearStagedUpdate(ctx context.Context) error { | |
| 194 | + | log := logging.FromContext(ctx) | |
| 195 | + | ||
| 196 | + | stagingDir := a.stagingDir() | |
| 197 | + | if err := os.RemoveAll(stagingDir); err != nil { | |
| 198 | + | return fmt.Errorf("failed to clear staged update: %w", err) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | log.Debug().Str("path", stagingDir).Msg("cleared staged update") | |
| 202 | + | return nil | |
| 203 | + | } | |
| @@ -0,0 +1,203 @@ | |||||
| 1 | + | package updater | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | ||||
| 9 | + | "golang.org/x/sys/unix" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | // Staging directory name within XDG_STATE_HOME. | |||
| 17 | + | stagingDirName = "pending-update" | |||
| 18 | + | // Staged binary filename. | |||
| 19 | + | stagedBinaryName = "dumber" | |||
| 20 | + | // Backup suffix for old binary. | |||
| 21 | + | backupSuffix = ".old" | |||
| 22 | + | // File permission for directories and executables. | |||
| 23 | + | applierExecPerm = 0o755 | |||
| 24 | + | // Execute permission bit for owner. | |||
| 25 | + | execBit = 0o100 | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | // Applier implements UpdateApplier for self-updating the binary. | |||
| 29 | + | type Applier struct { | |||
| 30 | + | stateDir string | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // NewApplier creates a new update applier. | |||
| 34 | + | // stateDir is the XDG state directory (e.g., ~/.local/state/dumber). | |||
| 35 | + | func NewApplier(stateDir string) *Applier { | |||
| 36 | + | return &Applier{ | |||
| 37 | + | stateDir: stateDir, | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // NewApplierFromXDG creates a new update applier using XDG directories. | |||
| 42 | + | func NewApplierFromXDG() (*Applier, error) { | |||
| 43 | + | stateDir, err := config.GetStateDir() | |||
| 44 | + | if err != nil { | |||
| 45 | + | return nil, fmt.Errorf("failed to get state directory: %w", err) | |||
| 46 | + | } | |||
| 47 | + | return NewApplier(stateDir), nil | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // stagingDir returns the path to the staging directory. | |||
| 51 | + | func (a *Applier) stagingDir() string { | |||
| 52 | + | return filepath.Join(a.stateDir, stagingDirName) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // stagedBinaryPath returns the path to the staged binary. | |||
| 56 | + | func (a *Applier) stagedBinaryPath() string { | |||
| 57 | + | return filepath.Join(a.stagingDir(), stagedBinaryName) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // CanSelfUpdate checks if the current binary is writable by the current user. | |||
| 61 | + | func (a *Applier) CanSelfUpdate(ctx context.Context) bool { | |||
| 62 | + | log := logging.FromContext(ctx) | |||
| 63 | + | ||||
| 64 | + | binaryPath, err := a.GetBinaryPath() | |||
| 65 | + | if err != nil { | |||
| 66 | + | log.Debug().Err(err).Msg("failed to get binary path") | |||
| 67 | + | return false | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Check if we have write permission on the binary file. | |||
| 71 | + | err = unix.Access(binaryPath, unix.W_OK) | |||
| 72 | + | if err != nil { | |||
| 73 | + | log.Debug(). | |||
| 74 | + | Str("path", binaryPath). | |||
| 75 | + | Err(err). | |||
| 76 | + | Msg("binary is not writable, self-update disabled") | |||
| 77 | + | return false | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | log.Debug().Str("path", binaryPath).Msg("binary is writable, self-update enabled") | |||
| 81 | + | return true | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // GetBinaryPath returns the path to the currently running binary. | |||
| 85 | + | func (*Applier) GetBinaryPath() (string, error) { | |||
| 86 | + | path, err := os.Executable() | |||
| 87 | + | if err != nil { | |||
| 88 | + | return "", fmt.Errorf("failed to get executable path: %w", err) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // Resolve any symlinks to get the actual binary path. | |||
| 92 | + | resolved, err := filepath.EvalSymlinks(path) | |||
| 93 | + | if err != nil { | |||
| 94 | + | return "", fmt.Errorf("failed to resolve symlinks: %w", err) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | return resolved, nil | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // StageUpdate copies the new binary to the staging directory. | |||
| 101 | + | func (a *Applier) StageUpdate(ctx context.Context, newBinaryPath string) error { | |||
| 102 | + | log := logging.FromContext(ctx) | |||
| 103 | + | ||||
| 104 | + | // Create staging directory. | |||
| 105 | + | stagingDir := a.stagingDir() | |||
| 106 | + | if err := os.MkdirAll(stagingDir, applierExecPerm); err != nil { | |||
| 107 | + | return fmt.Errorf("failed to create staging directory: %w", err) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // Read the new binary. | |||
| 111 | + | newBinary, err := os.ReadFile(newBinaryPath) | |||
| 112 | + | if err != nil { | |||
| 113 | + | return fmt.Errorf("failed to read new binary: %w", err) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // Write to staging location. | |||
| 117 | + | stagedPath := a.stagedBinaryPath() | |||
| 118 | + | if err := os.WriteFile(stagedPath, newBinary, applierExecPerm); err != nil { | |||
| 119 | + | return fmt.Errorf("failed to write staged binary: %w", err) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | log.Info(). | |||
| 123 | + | Str("staged_path", stagedPath). | |||
| 124 | + | Int("size", len(newBinary)). | |||
| 125 | + | Msg("update staged for exit") | |||
| 126 | + | ||||
| 127 | + | return nil | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // HasStagedUpdate checks if there is an update staged and ready to apply. | |||
| 131 | + | func (a *Applier) HasStagedUpdate(_ context.Context) bool { | |||
| 132 | + | stagedPath := a.stagedBinaryPath() | |||
| 133 | + | info, err := os.Stat(stagedPath) | |||
| 134 | + | if err != nil { | |||
| 135 | + | return false | |||
| 136 | + | } | |||
| 137 | + | // Ensure it's a regular file with execute permission. | |||
| 138 | + | return info.Mode().IsRegular() && info.Mode()&execBit != 0 | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | // ApplyOnExit replaces the current binary with the staged update. | |||
| 142 | + | // This should be called during graceful shutdown. | |||
| 143 | + | func (a *Applier) ApplyOnExit(ctx context.Context) (string, error) { | |||
| 144 | + | log := logging.FromContext(ctx) | |||
| 145 | + | ||||
| 146 | + | if !a.HasStagedUpdate(ctx) { | |||
| 147 | + | return "", fmt.Errorf("no staged update found") | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | binaryPath, err := a.GetBinaryPath() | |||
| 151 | + | if err != nil { | |||
| 152 | + | return "", err | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | stagedPath := a.stagedBinaryPath() | |||
| 156 | + | backupPath := binaryPath + backupSuffix | |||
| 157 | + | ||||
| 158 | + | log.Info(). | |||
| 159 | + | Str("current", binaryPath). | |||
| 160 | + | Str("staged", stagedPath). | |||
| 161 | + | Str("backup", backupPath). | |||
| 162 | + | Msg("applying staged update") | |||
| 163 | + | ||||
| 164 | + | // Step 1: Remove old backup if exists. | |||
| 165 | + | _ = os.Remove(backupPath) | |||
| 166 | + | ||||
| 167 | + | // Step 2: Rename current binary to backup. | |||
| 168 | + | if err := os.Rename(binaryPath, backupPath); err != nil { | |||
| 169 | + | return "", fmt.Errorf("failed to backup current binary: %w", err) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // Step 3: Rename staged binary to current. | |||
| 173 | + | if err := os.Rename(stagedPath, binaryPath); err != nil { | |||
| 174 | + | // Try to restore backup. | |||
| 175 | + | if restoreErr := os.Rename(backupPath, binaryPath); restoreErr != nil { | |||
| 176 | + | log.Error().Err(restoreErr).Msg("failed to restore backup after update failure") | |||
| 177 | + | } | |||
| 178 | + | return "", fmt.Errorf("failed to install new binary: %w", err) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // Step 4: Clean up staging directory. | |||
| 182 | + | _ = os.RemoveAll(a.stagingDir()) | |||
| 183 | + | ||||
| 184 | + | log.Info(). | |||
| 185 | + | Str("binary", binaryPath). | |||
| 186 | + | Str("backup", backupPath). | |||
| 187 | + | Msg("update applied successfully") | |||
| 188 | + | ||||
| 189 | + | return backupPath, nil | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // ClearStagedUpdate removes any staged update without applying it. | |||
| 193 | + | func (a *Applier) ClearStagedUpdate(ctx context.Context) error { | |||
| 194 | + | log := logging.FromContext(ctx) | |||
| 195 | + | ||||
| 196 | + | stagingDir := a.stagingDir() | |||
| 197 | + | if err := os.RemoveAll(stagingDir); err != nil { | |||
| 198 | + | return fmt.Errorf("failed to clear staged update: %w", err) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | log.Debug().Str("path", stagingDir).Msg("cleared staged update") | |||
| 202 | + | return nil | |||
| 203 | + | } | |||
A internal/infrastructure/updater/github.go +309 -0
| @@ -0,0 +1,309 @@ | |||
| 1 | + | // Package updater provides update checking and downloading functionality. | |
| 2 | + | package updater | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "archive/tar" | |
| 6 | + | "compress/gzip" | |
| 7 | + | "context" | |
| 8 | + | "encoding/json" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "io" | |
| 12 | + | "net/http" | |
| 13 | + | "os" | |
| 14 | + | "path/filepath" | |
| 15 | + | "regexp" | |
| 16 | + | "runtime" | |
| 17 | + | "strconv" | |
| 18 | + | "strings" | |
| 19 | + | "time" | |
| 20 | + | ||
| 21 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 22 | + | "github.com/bnema/dumber/internal/logging" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | const ( | |
| 26 | + | // GitHub API endpoint for latest release. | |
| 27 | + | githubAPIURL = "https://api.github.com/repos/bnema/dumber/releases/latest" | |
| 28 | + | ||
| 29 | + | // Download URL pattern for the release archive. | |
| 30 | + | // Uses version-less filename for stable "latest" download URL. | |
| 31 | + | downloadURLTemplate = "https://github.com/bnema/dumber/releases/latest/download/dumber_%s_%s.tar.gz" | |
| 32 | + | ||
| 33 | + | // HTTP client timeout for API requests. | |
| 34 | + | apiTimeout = 10 * time.Second | |
| 35 | + | ||
| 36 | + | // HTTP client timeout for downloads. | |
| 37 | + | downloadTimeout = 5 * time.Minute | |
| 38 | + | ||
| 39 | + | // File permission for directories and executables. | |
| 40 | + | execPerm = 0o755 | |
| 41 | + | ||
| 42 | + | // Maximum size for extracted binary (100MB) - prevents decompression bombs. | |
| 43 | + | maxBinarySize = 100 * 1024 * 1024 | |
| 44 | + | ) | |
| 45 | + | ||
| 46 | + | // githubRelease represents the GitHub API response for a release. | |
| 47 | + | type githubRelease struct { | |
| 48 | + | TagName string `json:"tag_name"` | |
| 49 | + | HTMLURL string `json:"html_url"` | |
| 50 | + | PublishedAt time.Time `json:"published_at"` | |
| 51 | + | Body string `json:"body"` | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // GitHubChecker implements UpdateChecker using the GitHub API. | |
| 55 | + | type GitHubChecker struct { | |
| 56 | + | client *http.Client | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // NewGitHubChecker creates a new GitHub-based update checker. | |
| 60 | + | func NewGitHubChecker() *GitHubChecker { | |
| 61 | + | return &GitHubChecker{ | |
| 62 | + | client: &http.Client{Timeout: apiTimeout}, | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // CheckForUpdate checks if a newer version is available on GitHub. | |
| 67 | + | func (g *GitHubChecker) CheckForUpdate(ctx context.Context, currentVersion string) (*entity.UpdateInfo, error) { | |
| 68 | + | log := logging.FromContext(ctx) | |
| 69 | + | ||
| 70 | + | // Skip check for dev builds. | |
| 71 | + | if currentVersion == "" || currentVersion == "dev" { | |
| 72 | + | log.Debug().Str("version", currentVersion).Msg("skipping update check for dev build") | |
| 73 | + | return &entity.UpdateInfo{ | |
| 74 | + | CurrentVersion: currentVersion, | |
| 75 | + | LatestVersion: currentVersion, | |
| 76 | + | IsNewer: false, | |
| 77 | + | }, nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPIURL, http.NoBody) | |
| 81 | + | if err != nil { | |
| 82 | + | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 83 | + | } | |
| 84 | + | req.Header.Set("Accept", "application/vnd.github.v3+json") | |
| 85 | + | req.Header.Set("User-Agent", "dumber-browser/"+currentVersion) | |
| 86 | + | ||
| 87 | + | resp, err := g.client.Do(req) | |
| 88 | + | if err != nil { | |
| 89 | + | return nil, fmt.Errorf("failed to fetch latest release: %w", err) | |
| 90 | + | } | |
| 91 | + | defer func() { _ = resp.Body.Close() }() | |
| 92 | + | ||
| 93 | + | if resp.StatusCode != http.StatusOK { | |
| 94 | + | return nil, fmt.Errorf("github API returned status %d", resp.StatusCode) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | var release githubRelease | |
| 98 | + | if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { | |
| 99 | + | return nil, fmt.Errorf("failed to decode release: %w", err) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | latestVersion := strings.TrimPrefix(release.TagName, "v") | |
| 103 | + | currentClean := strings.TrimPrefix(currentVersion, "v") | |
| 104 | + | ||
| 105 | + | isNewer := compareVersions(currentClean, latestVersion) < 0 | |
| 106 | + | ||
| 107 | + | // Build download URL based on current architecture. | |
| 108 | + | archName := getArchName() | |
| 109 | + | downloadURL := fmt.Sprintf(downloadURLTemplate, runtime.GOOS, archName) | |
| 110 | + | ||
| 111 | + | log.Debug(). | |
| 112 | + | Str("current", currentVersion). | |
| 113 | + | Str("latest", latestVersion). | |
| 114 | + | Bool("is_newer", isNewer). | |
| 115 | + | Msg("update check completed") | |
| 116 | + | ||
| 117 | + | return &entity.UpdateInfo{ | |
| 118 | + | CurrentVersion: currentVersion, | |
| 119 | + | LatestVersion: latestVersion, | |
| 120 | + | IsNewer: isNewer, | |
| 121 | + | ReleaseURL: release.HTMLURL, | |
| 122 | + | DownloadURL: downloadURL, | |
| 123 | + | PublishedAt: release.PublishedAt, | |
| 124 | + | ReleaseNotes: release.Body, | |
| 125 | + | }, nil | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // getArchName returns the architecture name used in release assets. | |
| 129 | + | func getArchName() string { | |
| 130 | + | switch runtime.GOARCH { | |
| 131 | + | case "amd64": | |
| 132 | + | return "x86_64" | |
| 133 | + | case "386": | |
| 134 | + | return "i386" | |
| 135 | + | default: | |
| 136 | + | return runtime.GOARCH | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // compareVersions compares two semantic versions. | |
| 141 | + | // Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2. | |
| 142 | + | func compareVersions(v1, v2 string) int { | |
| 143 | + | // Parse version components. | |
| 144 | + | parse := func(v string) (major, minor, patch int) { | |
| 145 | + | // Remove any pre-release suffix for comparison. | |
| 146 | + | v = regexp.MustCompile(`-.*$`).ReplaceAllString(v, "") | |
| 147 | + | parts := strings.Split(v, ".") | |
| 148 | + | if len(parts) >= 1 { | |
| 149 | + | major, _ = strconv.Atoi(parts[0]) | |
| 150 | + | } | |
| 151 | + | if len(parts) >= 2 { | |
| 152 | + | minor, _ = strconv.Atoi(parts[1]) | |
| 153 | + | } | |
| 154 | + | if len(parts) >= 3 { | |
| 155 | + | patch, _ = strconv.Atoi(parts[2]) | |
| 156 | + | } | |
| 157 | + | return | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | maj1, min1, pat1 := parse(v1) | |
| 161 | + | maj2, min2, pat2 := parse(v2) | |
| 162 | + | ||
| 163 | + | if maj1 != maj2 { | |
| 164 | + | if maj1 < maj2 { | |
| 165 | + | return -1 | |
| 166 | + | } | |
| 167 | + | return 1 | |
| 168 | + | } | |
| 169 | + | if min1 != min2 { | |
| 170 | + | if min1 < min2 { | |
| 171 | + | return -1 | |
| 172 | + | } | |
| 173 | + | return 1 | |
| 174 | + | } | |
| 175 | + | if pat1 != pat2 { | |
| 176 | + | if pat1 < pat2 { | |
| 177 | + | return -1 | |
| 178 | + | } | |
| 179 | + | return 1 | |
| 180 | + | } | |
| 181 | + | return 0 | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // GitHubDownloader implements UpdateDownloader for GitHub releases. | |
| 185 | + | type GitHubDownloader struct { | |
| 186 | + | client *http.Client | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // NewGitHubDownloader creates a new GitHub release downloader. | |
| 190 | + | func NewGitHubDownloader() *GitHubDownloader { | |
| 191 | + | return &GitHubDownloader{ | |
| 192 | + | client: &http.Client{Timeout: downloadTimeout}, | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // Download fetches the update archive from the given URL. | |
| 197 | + | func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir string) (string, error) { | |
| 198 | + | log := logging.FromContext(ctx) | |
| 199 | + | ||
| 200 | + | // Ensure destination directory exists. | |
| 201 | + | if err := os.MkdirAll(destDir, execPerm); err != nil { | |
| 202 | + | return "", fmt.Errorf("failed to create download directory: %w", err) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // Create the request. | |
| 206 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, http.NoBody) | |
| 207 | + | if err != nil { | |
| 208 | + | return "", fmt.Errorf("failed to create download request: %w", err) | |
| 209 | + | } | |
| 210 | + | req.Header.Set("User-Agent", "dumber-browser") | |
| 211 | + | ||
| 212 | + | log.Debug().Str("url", downloadURL).Msg("downloading update") | |
| 213 | + | ||
| 214 | + | resp, err := g.client.Do(req) | |
| 215 | + | if err != nil { | |
| 216 | + | return "", fmt.Errorf("failed to download: %w", err) | |
| 217 | + | } | |
| 218 | + | defer func() { _ = resp.Body.Close() }() | |
| 219 | + | ||
| 220 | + | if resp.StatusCode != http.StatusOK { | |
| 221 | + | return "", fmt.Errorf("download failed with status %d", resp.StatusCode) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // Extract filename from URL. | |
| 225 | + | archivePath := filepath.Join(destDir, "dumber_update.tar.gz") | |
| 226 | + | ||
| 227 | + | // Create the file. | |
| 228 | + | file, err := os.Create(archivePath) | |
| 229 | + | if err != nil { | |
| 230 | + | return "", fmt.Errorf("failed to create archive file: %w", err) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // Copy the response body to the file. | |
| 234 | + | written, err := io.Copy(file, resp.Body) | |
| 235 | + | if closeErr := file.Close(); closeErr != nil && err == nil { | |
| 236 | + | err = closeErr | |
| 237 | + | } | |
| 238 | + | if err != nil { | |
| 239 | + | _ = os.Remove(archivePath) | |
| 240 | + | return "", fmt.Errorf("failed to write archive: %w", err) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | log.Debug(). | |
| 244 | + | Int64("bytes", written). | |
| 245 | + | Str("path", archivePath). | |
| 246 | + | Msg("download completed") | |
| 247 | + | ||
| 248 | + | return archivePath, nil | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // Extract extracts the dumber binary from the tar.gz archive. | |
| 252 | + | func (*GitHubDownloader) Extract(ctx context.Context, archivePath, destDir string) (string, error) { | |
| 253 | + | log := logging.FromContext(ctx) | |
| 254 | + | ||
| 255 | + | file, err := os.Open(archivePath) | |
| 256 | + | if err != nil { | |
| 257 | + | return "", fmt.Errorf("failed to open archive: %w", err) | |
| 258 | + | } | |
| 259 | + | defer func() { _ = file.Close() }() | |
| 260 | + | ||
| 261 | + | gzr, err := gzip.NewReader(file) | |
| 262 | + | if err != nil { | |
| 263 | + | return "", fmt.Errorf("failed to create gzip reader: %w", err) | |
| 264 | + | } | |
| 265 | + | defer func() { _ = gzr.Close() }() | |
| 266 | + | ||
| 267 | + | tr := tar.NewReader(gzr) | |
| 268 | + | ||
| 269 | + | var binaryPath string | |
| 270 | + | ||
| 271 | + | for { | |
| 272 | + | header, err := tr.Next() | |
| 273 | + | if err == io.EOF { | |
| 274 | + | break | |
| 275 | + | } | |
| 276 | + | if err != nil { | |
| 277 | + | return "", fmt.Errorf("failed to read tar: %w", err) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // Look for the dumber binary (inside dumber_VERSION/ directory). | |
| 281 | + | if header.Typeflag == tar.TypeReg && strings.HasSuffix(header.Name, "/dumber") { | |
| 282 | + | binaryPath = filepath.Join(destDir, "dumber") | |
| 283 | + | ||
| 284 | + | outFile, err := os.OpenFile(binaryPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, execPerm) | |
| 285 | + | if err != nil { | |
| 286 | + | return "", fmt.Errorf("failed to create binary file: %w", err) | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // Limit copy to prevent decompression bombs (G110). | |
| 290 | + | written, err := io.CopyN(outFile, tr, maxBinarySize) | |
| 291 | + | if closeErr := outFile.Close(); closeErr != nil && err == nil { | |
| 292 | + | err = closeErr | |
| 293 | + | } | |
| 294 | + | if err != nil && !errors.Is(err, io.EOF) { | |
| 295 | + | _ = os.Remove(binaryPath) | |
| 296 | + | return "", fmt.Errorf("failed to extract binary: %w", err) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | log.Debug().Int64("bytes", written).Str("path", binaryPath).Msg("extracted binary") | |
| 300 | + | break | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | if binaryPath == "" { | |
| 305 | + | return "", fmt.Errorf("dumber binary not found in archive") | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | return binaryPath, nil | |
| 309 | + | } | |
| @@ -0,0 +1,309 @@ | |||||
| 1 | + | // Package updater provides update checking and downloading functionality. | |||
| 2 | + | package updater | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "archive/tar" | |||
| 6 | + | "compress/gzip" | |||
| 7 | + | "context" | |||
| 8 | + | "encoding/json" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "io" | |||
| 12 | + | "net/http" | |||
| 13 | + | "os" | |||
| 14 | + | "path/filepath" | |||
| 15 | + | "regexp" | |||
| 16 | + | "runtime" | |||
| 17 | + | "strconv" | |||
| 18 | + | "strings" | |||
| 19 | + | "time" | |||
| 20 | + | ||||
| 21 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 22 | + | "github.com/bnema/dumber/internal/logging" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | const ( | |||
| 26 | + | // GitHub API endpoint for latest release. | |||
| 27 | + | githubAPIURL = "https://api.github.com/repos/bnema/dumber/releases/latest" | |||
| 28 | + | ||||
| 29 | + | // Download URL pattern for the release archive. | |||
| 30 | + | // Uses version-less filename for stable "latest" download URL. | |||
| 31 | + | downloadURLTemplate = "https://github.com/bnema/dumber/releases/latest/download/dumber_%s_%s.tar.gz" | |||
| 32 | + | ||||
| 33 | + | // HTTP client timeout for API requests. | |||
| 34 | + | apiTimeout = 10 * time.Second | |||
| 35 | + | ||||
| 36 | + | // HTTP client timeout for downloads. | |||
| 37 | + | downloadTimeout = 5 * time.Minute | |||
| 38 | + | ||||
| 39 | + | // File permission for directories and executables. | |||
| 40 | + | execPerm = 0o755 | |||
| 41 | + | ||||
| 42 | + | // Maximum size for extracted binary (100MB) - prevents decompression bombs. | |||
| 43 | + | maxBinarySize = 100 * 1024 * 1024 | |||
| 44 | + | ) | |||
| 45 | + | ||||
| 46 | + | // githubRelease represents the GitHub API response for a release. | |||
| 47 | + | type githubRelease struct { | |||
| 48 | + | TagName string `json:"tag_name"` | |||
| 49 | + | HTMLURL string `json:"html_url"` | |||
| 50 | + | PublishedAt time.Time `json:"published_at"` | |||
| 51 | + | Body string `json:"body"` | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // GitHubChecker implements UpdateChecker using the GitHub API. | |||
| 55 | + | type GitHubChecker struct { | |||
| 56 | + | client *http.Client | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // NewGitHubChecker creates a new GitHub-based update checker. | |||
| 60 | + | func NewGitHubChecker() *GitHubChecker { | |||
| 61 | + | return &GitHubChecker{ | |||
| 62 | + | client: &http.Client{Timeout: apiTimeout}, | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // CheckForUpdate checks if a newer version is available on GitHub. | |||
| 67 | + | func (g *GitHubChecker) CheckForUpdate(ctx context.Context, currentVersion string) (*entity.UpdateInfo, error) { | |||
| 68 | + | log := logging.FromContext(ctx) | |||
| 69 | + | ||||
| 70 | + | // Skip check for dev builds. | |||
| 71 | + | if currentVersion == "" || currentVersion == "dev" { | |||
| 72 | + | log.Debug().Str("version", currentVersion).Msg("skipping update check for dev build") | |||
| 73 | + | return &entity.UpdateInfo{ | |||
| 74 | + | CurrentVersion: currentVersion, | |||
| 75 | + | LatestVersion: currentVersion, | |||
| 76 | + | IsNewer: false, | |||
| 77 | + | }, nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPIURL, http.NoBody) | |||
| 81 | + | if err != nil { | |||
| 82 | + | return nil, fmt.Errorf("failed to create request: %w", err) | |||
| 83 | + | } | |||
| 84 | + | req.Header.Set("Accept", "application/vnd.github.v3+json") | |||
| 85 | + | req.Header.Set("User-Agent", "dumber-browser/"+currentVersion) | |||
| 86 | + | ||||
| 87 | + | resp, err := g.client.Do(req) | |||
| 88 | + | if err != nil { | |||
| 89 | + | return nil, fmt.Errorf("failed to fetch latest release: %w", err) | |||
| 90 | + | } | |||
| 91 | + | defer func() { _ = resp.Body.Close() }() | |||
| 92 | + | ||||
| 93 | + | if resp.StatusCode != http.StatusOK { | |||
| 94 | + | return nil, fmt.Errorf("github API returned status %d", resp.StatusCode) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | var release githubRelease | |||
| 98 | + | if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { | |||
| 99 | + | return nil, fmt.Errorf("failed to decode release: %w", err) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | latestVersion := strings.TrimPrefix(release.TagName, "v") | |||
| 103 | + | currentClean := strings.TrimPrefix(currentVersion, "v") | |||
| 104 | + | ||||
| 105 | + | isNewer := compareVersions(currentClean, latestVersion) < 0 | |||
| 106 | + | ||||
| 107 | + | // Build download URL based on current architecture. | |||
| 108 | + | archName := getArchName() | |||
| 109 | + | downloadURL := fmt.Sprintf(downloadURLTemplate, runtime.GOOS, archName) | |||
| 110 | + | ||||
| 111 | + | log.Debug(). | |||
| 112 | + | Str("current", currentVersion). | |||
| 113 | + | Str("latest", latestVersion). | |||
| 114 | + | Bool("is_newer", isNewer). | |||
| 115 | + | Msg("update check completed") | |||
| 116 | + | ||||
| 117 | + | return &entity.UpdateInfo{ | |||
| 118 | + | CurrentVersion: currentVersion, | |||
| 119 | + | LatestVersion: latestVersion, | |||
| 120 | + | IsNewer: isNewer, | |||
| 121 | + | ReleaseURL: release.HTMLURL, | |||
| 122 | + | DownloadURL: downloadURL, | |||
| 123 | + | PublishedAt: release.PublishedAt, | |||
| 124 | + | ReleaseNotes: release.Body, | |||
| 125 | + | }, nil | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // getArchName returns the architecture name used in release assets. | |||
| 129 | + | func getArchName() string { | |||
| 130 | + | switch runtime.GOARCH { | |||
| 131 | + | case "amd64": | |||
| 132 | + | return "x86_64" | |||
| 133 | + | case "386": | |||
| 134 | + | return "i386" | |||
| 135 | + | default: | |||
| 136 | + | return runtime.GOARCH | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // compareVersions compares two semantic versions. | |||
| 141 | + | // Returns -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2. | |||
| 142 | + | func compareVersions(v1, v2 string) int { | |||
| 143 | + | // Parse version components. | |||
| 144 | + | parse := func(v string) (major, minor, patch int) { | |||
| 145 | + | // Remove any pre-release suffix for comparison. | |||
| 146 | + | v = regexp.MustCompile(`-.*$`).ReplaceAllString(v, "") | |||
| 147 | + | parts := strings.Split(v, ".") | |||
| 148 | + | if len(parts) >= 1 { | |||
| 149 | + | major, _ = strconv.Atoi(parts[0]) | |||
| 150 | + | } | |||
| 151 | + | if len(parts) >= 2 { | |||
| 152 | + | minor, _ = strconv.Atoi(parts[1]) | |||
| 153 | + | } | |||
| 154 | + | if len(parts) >= 3 { | |||
| 155 | + | patch, _ = strconv.Atoi(parts[2]) | |||
| 156 | + | } | |||
| 157 | + | return | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | maj1, min1, pat1 := parse(v1) | |||
| 161 | + | maj2, min2, pat2 := parse(v2) | |||
| 162 | + | ||||
| 163 | + | if maj1 != maj2 { | |||
| 164 | + | if maj1 < maj2 { | |||
| 165 | + | return -1 | |||
| 166 | + | } | |||
| 167 | + | return 1 | |||
| 168 | + | } | |||
| 169 | + | if min1 != min2 { | |||
| 170 | + | if min1 < min2 { | |||
| 171 | + | return -1 | |||
| 172 | + | } | |||
| 173 | + | return 1 | |||
| 174 | + | } | |||
| 175 | + | if pat1 != pat2 { | |||
| 176 | + | if pat1 < pat2 { | |||
| 177 | + | return -1 | |||
| 178 | + | } | |||
| 179 | + | return 1 | |||
| 180 | + | } | |||
| 181 | + | return 0 | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | // GitHubDownloader implements UpdateDownloader for GitHub releases. | |||
| 185 | + | type GitHubDownloader struct { | |||
| 186 | + | client *http.Client | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // NewGitHubDownloader creates a new GitHub release downloader. | |||
| 190 | + | func NewGitHubDownloader() *GitHubDownloader { | |||
| 191 | + | return &GitHubDownloader{ | |||
| 192 | + | client: &http.Client{Timeout: downloadTimeout}, | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // Download fetches the update archive from the given URL. | |||
| 197 | + | func (g *GitHubDownloader) Download(ctx context.Context, downloadURL, destDir string) (string, error) { | |||
| 198 | + | log := logging.FromContext(ctx) | |||
| 199 | + | ||||
| 200 | + | // Ensure destination directory exists. | |||
| 201 | + | if err := os.MkdirAll(destDir, execPerm); err != nil { | |||
| 202 | + | return "", fmt.Errorf("failed to create download directory: %w", err) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // Create the request. | |||
| 206 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, http.NoBody) | |||
| 207 | + | if err != nil { | |||
| 208 | + | return "", fmt.Errorf("failed to create download request: %w", err) | |||
| 209 | + | } | |||
| 210 | + | req.Header.Set("User-Agent", "dumber-browser") | |||
| 211 | + | ||||
| 212 | + | log.Debug().Str("url", downloadURL).Msg("downloading update") | |||
| 213 | + | ||||
| 214 | + | resp, err := g.client.Do(req) | |||
| 215 | + | if err != nil { | |||
| 216 | + | return "", fmt.Errorf("failed to download: %w", err) | |||
| 217 | + | } | |||
| 218 | + | defer func() { _ = resp.Body.Close() }() | |||
| 219 | + | ||||
| 220 | + | if resp.StatusCode != http.StatusOK { | |||
| 221 | + | return "", fmt.Errorf("download failed with status %d", resp.StatusCode) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // Extract filename from URL. | |||
| 225 | + | archivePath := filepath.Join(destDir, "dumber_update.tar.gz") | |||
| 226 | + | ||||
| 227 | + | // Create the file. | |||
| 228 | + | file, err := os.Create(archivePath) | |||
| 229 | + | if err != nil { | |||
| 230 | + | return "", fmt.Errorf("failed to create archive file: %w", err) | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // Copy the response body to the file. | |||
| 234 | + | written, err := io.Copy(file, resp.Body) | |||
| 235 | + | if closeErr := file.Close(); closeErr != nil && err == nil { | |||
| 236 | + | err = closeErr | |||
| 237 | + | } | |||
| 238 | + | if err != nil { | |||
| 239 | + | _ = os.Remove(archivePath) | |||
| 240 | + | return "", fmt.Errorf("failed to write archive: %w", err) | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | log.Debug(). | |||
| 244 | + | Int64("bytes", written). | |||
| 245 | + | Str("path", archivePath). | |||
| 246 | + | Msg("download completed") | |||
| 247 | + | ||||
| 248 | + | return archivePath, nil | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | // Extract extracts the dumber binary from the tar.gz archive. | |||
| 252 | + | func (*GitHubDownloader) Extract(ctx context.Context, archivePath, destDir string) (string, error) { | |||
| 253 | + | log := logging.FromContext(ctx) | |||
| 254 | + | ||||
| 255 | + | file, err := os.Open(archivePath) | |||
| 256 | + | if err != nil { | |||
| 257 | + | return "", fmt.Errorf("failed to open archive: %w", err) | |||
| 258 | + | } | |||
| 259 | + | defer func() { _ = file.Close() }() | |||
| 260 | + | ||||
| 261 | + | gzr, err := gzip.NewReader(file) | |||
| 262 | + | if err != nil { | |||
| 263 | + | return "", fmt.Errorf("failed to create gzip reader: %w", err) | |||
| 264 | + | } | |||
| 265 | + | defer func() { _ = gzr.Close() }() | |||
| 266 | + | ||||
| 267 | + | tr := tar.NewReader(gzr) | |||
| 268 | + | ||||
| 269 | + | var binaryPath string | |||
| 270 | + | ||||
| 271 | + | for { | |||
| 272 | + | header, err := tr.Next() | |||
| 273 | + | if err == io.EOF { | |||
| 274 | + | break | |||
| 275 | + | } | |||
| 276 | + | if err != nil { | |||
| 277 | + | return "", fmt.Errorf("failed to read tar: %w", err) | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // Look for the dumber binary (inside dumber_VERSION/ directory). | |||
| 281 | + | if header.Typeflag == tar.TypeReg && strings.HasSuffix(header.Name, "/dumber") { | |||
| 282 | + | binaryPath = filepath.Join(destDir, "dumber") | |||
| 283 | + | ||||
| 284 | + | outFile, err := os.OpenFile(binaryPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, execPerm) | |||
| 285 | + | if err != nil { | |||
| 286 | + | return "", fmt.Errorf("failed to create binary file: %w", err) | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | // Limit copy to prevent decompression bombs (G110). | |||
| 290 | + | written, err := io.CopyN(outFile, tr, maxBinarySize) | |||
| 291 | + | if closeErr := outFile.Close(); closeErr != nil && err == nil { | |||
| 292 | + | err = closeErr | |||
| 293 | + | } | |||
| 294 | + | if err != nil && !errors.Is(err, io.EOF) { | |||
| 295 | + | _ = os.Remove(binaryPath) | |||
| 296 | + | return "", fmt.Errorf("failed to extract binary: %w", err) | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | log.Debug().Int64("bytes", written).Str("path", binaryPath).Msg("extracted binary") | |||
| 300 | + | break | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | if binaryPath == "" { | |||
| 305 | + | return "", fmt.Errorf("dumber binary not found in archive") | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | return binaryPath, nil | |||
| 309 | + | } | |||
A internal/infrastructure/updater/github_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | package updater | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | func TestCompareVersions(t *testing.T) { | |
| 8 | + | tests := []struct { | |
| 9 | + | name string | |
| 10 | + | v1 string | |
| 11 | + | v2 string | |
| 12 | + | expected int | |
| 13 | + | }{ | |
| 14 | + | // Equal versions | |
| 15 | + | {"equal simple", "1.0.0", "1.0.0", 0}, | |
| 16 | + | {"equal two parts", "1.0", "1.0", 0}, | |
| 17 | + | {"equal one part", "1", "1", 0}, | |
| 18 | + | ||
| 19 | + | // v1 < v2 (should update) | |
| 20 | + | {"major less", "1.0.0", "2.0.0", -1}, | |
| 21 | + | {"minor less", "1.1.0", "1.2.0", -1}, | |
| 22 | + | {"patch less", "1.1.1", "1.1.2", -1}, | |
| 23 | + | {"complex less", "0.20.1", "0.21.0", -1}, | |
| 24 | + | {"real world less", "0.20.1", "0.20.2", -1}, | |
| 25 | + | ||
| 26 | + | // v1 > v2 (no update needed) | |
| 27 | + | {"major greater", "2.0.0", "1.0.0", 1}, | |
| 28 | + | {"minor greater", "1.2.0", "1.1.0", 1}, | |
| 29 | + | {"patch greater", "1.1.2", "1.1.1", 1}, | |
| 30 | + | ||
| 31 | + | // Pre-release versions (suffix stripped) | |
| 32 | + | {"prerelease equal", "1.0.0-alpha", "1.0.0", 0}, | |
| 33 | + | {"prerelease less", "1.0.0-alpha", "1.0.1", -1}, | |
| 34 | + | {"prerelease greater", "1.0.1-beta", "1.0.0", 1}, | |
| 35 | + | ||
| 36 | + | // Partial versions | |
| 37 | + | {"partial v1", "1", "1.0.0", 0}, | |
| 38 | + | {"partial v2", "1.0.0", "1", 0}, | |
| 39 | + | {"partial less", "1", "2.0.0", -1}, | |
| 40 | + | {"partial greater", "2", "1.0.0", 1}, | |
| 41 | + | ||
| 42 | + | // Edge cases | |
| 43 | + | {"zero versions", "0.0.0", "0.0.0", 0}, | |
| 44 | + | {"zero less", "0.0.0", "0.0.1", -1}, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | for _, tt := range tests { | |
| 48 | + | t.Run(tt.name, func(t *testing.T) { | |
| 49 | + | result := compareVersions(tt.v1, tt.v2) | |
| 50 | + | if result != tt.expected { | |
| 51 | + | t.Errorf("compareVersions(%q, %q) = %d, want %d", tt.v1, tt.v2, result, tt.expected) | |
| 52 | + | } | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestGetArchName(t *testing.T) { | |
| 58 | + | // This test just ensures the function doesn't panic and returns something. | |
| 59 | + | arch := getArchName() | |
| 60 | + | if arch == "" { | |
| 61 | + | t.Error("getArchName() returned empty string") | |
| 62 | + | } | |
| 63 | + | } | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | package updater | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ) | |||
| 6 | + | ||||
| 7 | + | func TestCompareVersions(t *testing.T) { | |||
| 8 | + | tests := []struct { | |||
| 9 | + | name string | |||
| 10 | + | v1 string | |||
| 11 | + | v2 string | |||
| 12 | + | expected int | |||
| 13 | + | }{ | |||
| 14 | + | // Equal versions | |||
| 15 | + | {"equal simple", "1.0.0", "1.0.0", 0}, | |||
| 16 | + | {"equal two parts", "1.0", "1.0", 0}, | |||
| 17 | + | {"equal one part", "1", "1", 0}, | |||
| 18 | + | ||||
| 19 | + | // v1 < v2 (should update) | |||
| 20 | + | {"major less", "1.0.0", "2.0.0", -1}, | |||
| 21 | + | {"minor less", "1.1.0", "1.2.0", -1}, | |||
| 22 | + | {"patch less", "1.1.1", "1.1.2", -1}, | |||
| 23 | + | {"complex less", "0.20.1", "0.21.0", -1}, | |||
| 24 | + | {"real world less", "0.20.1", "0.20.2", -1}, | |||
| 25 | + | ||||
| 26 | + | // v1 > v2 (no update needed) | |||
| 27 | + | {"major greater", "2.0.0", "1.0.0", 1}, | |||
| 28 | + | {"minor greater", "1.2.0", "1.1.0", 1}, | |||
| 29 | + | {"patch greater", "1.1.2", "1.1.1", 1}, | |||
| 30 | + | ||||
| 31 | + | // Pre-release versions (suffix stripped) | |||
| 32 | + | {"prerelease equal", "1.0.0-alpha", "1.0.0", 0}, | |||
| 33 | + | {"prerelease less", "1.0.0-alpha", "1.0.1", -1}, | |||
| 34 | + | {"prerelease greater", "1.0.1-beta", "1.0.0", 1}, | |||
| 35 | + | ||||
| 36 | + | // Partial versions | |||
| 37 | + | {"partial v1", "1", "1.0.0", 0}, | |||
| 38 | + | {"partial v2", "1.0.0", "1", 0}, | |||
| 39 | + | {"partial less", "1", "2.0.0", -1}, | |||
| 40 | + | {"partial greater", "2", "1.0.0", 1}, | |||
| 41 | + | ||||
| 42 | + | // Edge cases | |||
| 43 | + | {"zero versions", "0.0.0", "0.0.0", 0}, | |||
| 44 | + | {"zero less", "0.0.0", "0.0.1", -1}, | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | for _, tt := range tests { | |||
| 48 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 49 | + | result := compareVersions(tt.v1, tt.v2) | |||
| 50 | + | if result != tt.expected { | |||
| 51 | + | t.Errorf("compareVersions(%q, %q) = %d, want %d", tt.v1, tt.v2, result, tt.expected) | |||
| 52 | + | } | |||
| 53 | + | }) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestGetArchName(t *testing.T) { | |||
| 58 | + | // This test just ensures the function doesn't panic and returns something. | |||
| 59 | + | arch := getArchName() | |||
| 60 | + | if arch == "" { | |||
| 61 | + | t.Error("getArchName() returned empty string") | |||
| 62 | + | } | |||
| 63 | + | } | |||
M internal/ui/app.go +32 -0
| @@ -90,6 +90,9 @@ type App struct { | |||
| 90 | 90 | sessionManager *component.SessionManager | |
| 91 | 91 | snapshotService *snapshot.Service | |
| 92 | 92 | ||
| 93 | + | // Update management | |
| 94 | + | updateCoord *coordinator.UpdateCoordinator | |
| 95 | + | ||
| 93 | 96 | // ID generator for tabs/panes | |
| 94 | 97 | idCounter uint64 | |
| 95 | 98 | idMu sync.Mutex | |
| @@ -191,6 +194,7 @@ func (a *App) onActivate(ctx context.Context) { | |||
| 191 | 194 | a.initFindBarConfig() | |
| 192 | 195 | a.initSessionManager(ctx) | |
| 193 | 196 | a.initSnapshotService(ctx) | |
| 197 | + | a.initUpdateCoordinator(ctx) | |
| 194 | 198 | a.createInitialTab(ctx) | |
| 195 | 199 | a.finalizeActivation(ctx) | |
| 196 | 200 | } | |
| @@ -476,6 +480,27 @@ func (a *App) initSnapshotService(ctx context.Context) { | |||
| 476 | 480 | log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started") | |
| 477 | 481 | } | |
| 478 | 482 | ||
| 483 | + | func (a *App) initUpdateCoordinator(ctx context.Context) { | |
| 484 | + | log := logging.FromContext(ctx) | |
| 485 | + | ||
| 486 | + | if a.deps == nil || a.deps.CheckUpdateUC == nil { | |
| 487 | + | log.Debug().Msg("update use cases not available, skipping update coordinator") | |
| 488 | + | return | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | a.updateCoord = coordinator.NewUpdateCoordinator( | |
| 492 | + | a.deps.CheckUpdateUC, | |
| 493 | + | a.deps.ApplyUpdateUC, | |
| 494 | + | a.appToaster, | |
| 495 | + | a.deps.Config, | |
| 496 | + | ) | |
| 497 | + | ||
| 498 | + | // Start async update check | |
| 499 | + | a.updateCoord.CheckOnStartup(ctx) | |
| 500 | + | ||
| 501 | + | log.Debug().Msg("update coordinator initialized") | |
| 502 | + | } | |
| 503 | + | ||
| 479 | 504 | // GetTabList implements port.TabListProvider. | |
| 480 | 505 | func (a *App) GetTabList() *entity.TabList { | |
| 481 | 506 | return a.tabs | |
| @@ -616,6 +641,13 @@ func (a *App) onShutdown(ctx context.Context) { | |||
| 616 | 641 | } | |
| 617 | 642 | } | |
| 618 | 643 | ||
| 644 | + | // Apply staged update if available (before cleanup) | |
| 645 | + | if a.updateCoord != nil { | |
| 646 | + | if err := a.updateCoord.FinalizeOnExit(ctx); err != nil { | |
| 647 | + | log.Warn().Err(err).Msg("failed to apply staged update") | |
| 648 | + | } | |
| 649 | + | } | |
| 650 | + | ||
| 619 | 651 | // Cancel context to signal all goroutines | |
| 620 | 652 | a.cancel(errors.New("application shutdown")) | |
| 621 | 653 | ||
| @@ -90,6 +90,9 @@ type App struct { | |||||
| 90 | sessionManager *component.SessionManager | 90 | sessionManager *component.SessionManager | ||
| 91 | snapshotService *snapshot.Service | 91 | snapshotService *snapshot.Service | ||
| 92 | 92 | ||||
| 93 | + | // Update management | |||
| 94 | + | updateCoord *coordinator.UpdateCoordinator | |||
| 95 | + | ||||
| 93 | // ID generator for tabs/panes | 96 | // ID generator for tabs/panes | ||
| 94 | idCounter uint64 | 97 | idCounter uint64 | ||
| 95 | idMu sync.Mutex | 98 | idMu sync.Mutex | ||
| @@ -191,6 +194,7 @@ func (a *App) onActivate(ctx context.Context) { | |||||
| 191 | a.initFindBarConfig() | 194 | a.initFindBarConfig() | ||
| 192 | a.initSessionManager(ctx) | 195 | a.initSessionManager(ctx) | ||
| 193 | a.initSnapshotService(ctx) | 196 | a.initSnapshotService(ctx) | ||
| 197 | + | a.initUpdateCoordinator(ctx) | |||
| 194 | a.createInitialTab(ctx) | 198 | a.createInitialTab(ctx) | ||
| 195 | a.finalizeActivation(ctx) | 199 | a.finalizeActivation(ctx) | ||
| 196 | } | 200 | } | ||
| @@ -476,6 +480,27 @@ func (a *App) initSnapshotService(ctx context.Context) { | |||||
| 476 | log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started") | 480 | log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started") | ||
| 477 | } | 481 | } | ||
| 478 | 482 | ||||
| 483 | + | func (a *App) initUpdateCoordinator(ctx context.Context) { | |||
| 484 | + | log := logging.FromContext(ctx) | |||
| 485 | + | ||||
| 486 | + | if a.deps == nil || a.deps.CheckUpdateUC == nil { | |||
| 487 | + | log.Debug().Msg("update use cases not available, skipping update coordinator") | |||
| 488 | + | return | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | a.updateCoord = coordinator.NewUpdateCoordinator( | |||
| 492 | + | a.deps.CheckUpdateUC, | |||
| 493 | + | a.deps.ApplyUpdateUC, | |||
| 494 | + | a.appToaster, | |||
| 495 | + | a.deps.Config, | |||
| 496 | + | ) | |||
| 497 | + | ||||
| 498 | + | // Start async update check | |||
| 499 | + | a.updateCoord.CheckOnStartup(ctx) | |||
| 500 | + | ||||
| 501 | + | log.Debug().Msg("update coordinator initialized") | |||
| 502 | + | } | |||
| 503 | + | ||||
| 479 | // GetTabList implements port.TabListProvider. | 504 | // GetTabList implements port.TabListProvider. | ||
| 480 | func (a *App) GetTabList() *entity.TabList { | 505 | func (a *App) GetTabList() *entity.TabList { | ||
| 481 | return a.tabs | 506 | return a.tabs | ||
| @@ -616,6 +641,13 @@ func (a *App) onShutdown(ctx context.Context) { | |||||
| 616 | } | 641 | } | ||
| 617 | } | 642 | } | ||
| 618 | 643 | ||||
| 644 | + | // Apply staged update if available (before cleanup) | |||
| 645 | + | if a.updateCoord != nil { | |||
| 646 | + | if err := a.updateCoord.FinalizeOnExit(ctx); err != nil { | |||
| 647 | + | log.Warn().Err(err).Msg("failed to apply staged update") | |||
| 648 | + | } | |||
| 649 | + | } | |||
| 650 | + | ||||
| 619 | // Cancel context to signal all goroutines | 651 | // Cancel context to signal all goroutines | ||
| 620 | a.cancel(errors.New("application shutdown")) | 652 | a.cancel(errors.New("application shutdown")) | ||
| 621 | 653 | ||||
A internal/ui/coordinator/update.go +172 -0
| @@ -0,0 +1,172 @@ | |||
| 1 | + | package coordinator | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 12 | + | "github.com/bnema/dumber/internal/logging" | |
| 13 | + | "github.com/bnema/dumber/internal/ui/component" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // UpdateCoordinator handles update checking and notification. | |
| 17 | + | type UpdateCoordinator struct { | |
| 18 | + | checkUC *usecase.CheckUpdateUseCase | |
| 19 | + | applyUC *usecase.ApplyUpdateUseCase | |
| 20 | + | toaster *component.Toaster | |
| 21 | + | config *config.Config | |
| 22 | + | status entity.UpdateStatus | |
| 23 | + | lastInfo *usecase.CheckUpdateOutput | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // NewUpdateCoordinator creates a new update coordinator. | |
| 27 | + | func NewUpdateCoordinator( | |
| 28 | + | checkUC *usecase.CheckUpdateUseCase, | |
| 29 | + | applyUC *usecase.ApplyUpdateUseCase, | |
| 30 | + | toaster *component.Toaster, | |
| 31 | + | cfg *config.Config, | |
| 32 | + | ) *UpdateCoordinator { | |
| 33 | + | return &UpdateCoordinator{ | |
| 34 | + | checkUC: checkUC, | |
| 35 | + | applyUC: applyUC, | |
| 36 | + | toaster: toaster, | |
| 37 | + | config: cfg, | |
| 38 | + | status: entity.UpdateStatusUnknown, | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // CheckOnStartup performs an async update check if enabled in config. | |
| 43 | + | // This should be called during app initialization. | |
| 44 | + | func (c *UpdateCoordinator) CheckOnStartup(ctx context.Context) { | |
| 45 | + | log := logging.FromContext(ctx) | |
| 46 | + | ||
| 47 | + | if !c.config.Update.EnableOnStartup { | |
| 48 | + | log.Debug().Msg("update check on startup disabled") | |
| 49 | + | return | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | go c.checkAsync(ctx) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // checkAsync performs the update check in a goroutine. | |
| 56 | + | func (c *UpdateCoordinator) checkAsync(ctx context.Context) { | |
| 57 | + | log := logging.FromContext(ctx) | |
| 58 | + | ||
| 59 | + | result, err := c.checkUC.Execute(ctx, usecase.CheckUpdateInput{}) | |
| 60 | + | if err != nil { | |
| 61 | + | log.Warn().Err(err).Msg("background update check failed") | |
| 62 | + | c.status = entity.UpdateStatusFailed | |
| 63 | + | return | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | c.lastInfo = result | |
| 67 | + | ||
| 68 | + | if !result.UpdateAvailable { | |
| 69 | + | c.status = entity.UpdateStatusUpToDate | |
| 70 | + | log.Debug(). | |
| 71 | + | Str("version", result.CurrentVersion). | |
| 72 | + | Msg("already on latest version") | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | c.status = entity.UpdateStatusAvailable | |
| 77 | + | log.Info(). | |
| 78 | + | Str("current", result.CurrentVersion). | |
| 79 | + | Str("latest", result.LatestVersion). | |
| 80 | + | Bool("can_auto_update", result.CanAutoUpdate). | |
| 81 | + | Msg("update available") | |
| 82 | + | ||
| 83 | + | // Show notification on GTK main thread. | |
| 84 | + | c.showUpdateNotification(ctx, result) | |
| 85 | + | ||
| 86 | + | // Auto-download if enabled and possible. | |
| 87 | + | if c.config.Update.AutoDownload && result.CanAutoUpdate { | |
| 88 | + | c.downloadAsync(ctx, result.DownloadURL) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // showUpdateNotification displays a toast notification about the update. | |
| 93 | + | func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *usecase.CheckUpdateOutput) { | |
| 94 | + | var msg string | |
| 95 | + | if result.CanAutoUpdate && c.config.Update.AutoDownload { | |
| 96 | + | msg = fmt.Sprintf("Downloading update %s...", result.LatestVersion) | |
| 97 | + | } else if result.CanAutoUpdate { | |
| 98 | + | msg = fmt.Sprintf("Update %s available", result.LatestVersion) | |
| 99 | + | } else { | |
| 100 | + | msg = fmt.Sprintf("Update %s available (manual install required)", result.LatestVersion) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // Dispatch to GTK main thread. | |
| 104 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 105 | + | c.toaster.Show(ctx, msg, component.ToastInfo) | |
| 106 | + | return false | |
| 107 | + | }) | |
| 108 | + | glib.IdleAdd(&cb, 0) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // downloadAsync downloads and stages the update in the background. | |
| 112 | + | func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL string) { | |
| 113 | + | log := logging.FromContext(ctx) | |
| 114 | + | ||
| 115 | + | c.status = entity.UpdateStatusDownloading | |
| 116 | + | ||
| 117 | + | result, err := c.applyUC.Execute(ctx, usecase.ApplyUpdateInput{ | |
| 118 | + | DownloadURL: downloadURL, | |
| 119 | + | }) | |
| 120 | + | if err != nil { | |
| 121 | + | log.Error().Err(err).Msg("update download failed") | |
| 122 | + | c.status = entity.UpdateStatusFailed | |
| 123 | + | c.showToast(ctx, "Update download failed", component.ToastError) | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | if result.Status == entity.UpdateStatusReady { | |
| 128 | + | c.status = entity.UpdateStatusReady | |
| 129 | + | log.Info().Msg("update ready, will apply on exit") | |
| 130 | + | c.showToast(ctx, "Update ready - applies on exit", component.ToastSuccess) | |
| 131 | + | } else { | |
| 132 | + | c.status = entity.UpdateStatusFailed | |
| 133 | + | log.Warn().Str("message", result.Message).Msg("update staging failed") | |
| 134 | + | c.showToast(ctx, result.Message, component.ToastWarning) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // showToast dispatches a toast notification to the GTK main thread. | |
| 139 | + | func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) { | |
| 140 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 141 | + | c.toaster.Show(ctx, msg, level) | |
| 142 | + | return false | |
| 143 | + | }) | |
| 144 | + | glib.IdleAdd(&cb, 0) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // FinalizeOnExit applies any staged update during shutdown. | |
| 148 | + | // This should be called from the app's shutdown handler. | |
| 149 | + | func (c *UpdateCoordinator) FinalizeOnExit(ctx context.Context) error { | |
| 150 | + | if c.applyUC == nil { | |
| 151 | + | return nil | |
| 152 | + | } | |
| 153 | + | return c.applyUC.FinalizeOnExit(ctx) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // Status returns the current update status. | |
| 157 | + | func (c *UpdateCoordinator) Status() entity.UpdateStatus { | |
| 158 | + | return c.status | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // LastCheckResult returns the result of the last update check. | |
| 162 | + | func (c *UpdateCoordinator) LastCheckResult() *usecase.CheckUpdateOutput { | |
| 163 | + | return c.lastInfo | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // HasPendingUpdate returns true if an update is staged for exit. | |
| 167 | + | func (c *UpdateCoordinator) HasPendingUpdate(ctx context.Context) bool { | |
| 168 | + | if c.applyUC == nil { | |
| 169 | + | return false | |
| 170 | + | } | |
| 171 | + | return c.applyUC.HasPendingUpdate(ctx) | |
| 172 | + | } | |
| @@ -0,0 +1,172 @@ | |||||
| 1 | + | package coordinator | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/jwijenbergh/puregotk/v4/glib" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 10 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 11 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 12 | + | "github.com/bnema/dumber/internal/logging" | |||
| 13 | + | "github.com/bnema/dumber/internal/ui/component" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // UpdateCoordinator handles update checking and notification. | |||
| 17 | + | type UpdateCoordinator struct { | |||
| 18 | + | checkUC *usecase.CheckUpdateUseCase | |||
| 19 | + | applyUC *usecase.ApplyUpdateUseCase | |||
| 20 | + | toaster *component.Toaster | |||
| 21 | + | config *config.Config | |||
| 22 | + | status entity.UpdateStatus | |||
| 23 | + | lastInfo *usecase.CheckUpdateOutput | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // NewUpdateCoordinator creates a new update coordinator. | |||
| 27 | + | func NewUpdateCoordinator( | |||
| 28 | + | checkUC *usecase.CheckUpdateUseCase, | |||
| 29 | + | applyUC *usecase.ApplyUpdateUseCase, | |||
| 30 | + | toaster *component.Toaster, | |||
| 31 | + | cfg *config.Config, | |||
| 32 | + | ) *UpdateCoordinator { | |||
| 33 | + | return &UpdateCoordinator{ | |||
| 34 | + | checkUC: checkUC, | |||
| 35 | + | applyUC: applyUC, | |||
| 36 | + | toaster: toaster, | |||
| 37 | + | config: cfg, | |||
| 38 | + | status: entity.UpdateStatusUnknown, | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // CheckOnStartup performs an async update check if enabled in config. | |||
| 43 | + | // This should be called during app initialization. | |||
| 44 | + | func (c *UpdateCoordinator) CheckOnStartup(ctx context.Context) { | |||
| 45 | + | log := logging.FromContext(ctx) | |||
| 46 | + | ||||
| 47 | + | if !c.config.Update.EnableOnStartup { | |||
| 48 | + | log.Debug().Msg("update check on startup disabled") | |||
| 49 | + | return | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | go c.checkAsync(ctx) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // checkAsync performs the update check in a goroutine. | |||
| 56 | + | func (c *UpdateCoordinator) checkAsync(ctx context.Context) { | |||
| 57 | + | log := logging.FromContext(ctx) | |||
| 58 | + | ||||
| 59 | + | result, err := c.checkUC.Execute(ctx, usecase.CheckUpdateInput{}) | |||
| 60 | + | if err != nil { | |||
| 61 | + | log.Warn().Err(err).Msg("background update check failed") | |||
| 62 | + | c.status = entity.UpdateStatusFailed | |||
| 63 | + | return | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | c.lastInfo = result | |||
| 67 | + | ||||
| 68 | + | if !result.UpdateAvailable { | |||
| 69 | + | c.status = entity.UpdateStatusUpToDate | |||
| 70 | + | log.Debug(). | |||
| 71 | + | Str("version", result.CurrentVersion). | |||
| 72 | + | Msg("already on latest version") | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | c.status = entity.UpdateStatusAvailable | |||
| 77 | + | log.Info(). | |||
| 78 | + | Str("current", result.CurrentVersion). | |||
| 79 | + | Str("latest", result.LatestVersion). | |||
| 80 | + | Bool("can_auto_update", result.CanAutoUpdate). | |||
| 81 | + | Msg("update available") | |||
| 82 | + | ||||
| 83 | + | // Show notification on GTK main thread. | |||
| 84 | + | c.showUpdateNotification(ctx, result) | |||
| 85 | + | ||||
| 86 | + | // Auto-download if enabled and possible. | |||
| 87 | + | if c.config.Update.AutoDownload && result.CanAutoUpdate { | |||
| 88 | + | c.downloadAsync(ctx, result.DownloadURL) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // showUpdateNotification displays a toast notification about the update. | |||
| 93 | + | func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *usecase.CheckUpdateOutput) { | |||
| 94 | + | var msg string | |||
| 95 | + | if result.CanAutoUpdate && c.config.Update.AutoDownload { | |||
| 96 | + | msg = fmt.Sprintf("Downloading update %s...", result.LatestVersion) | |||
| 97 | + | } else if result.CanAutoUpdate { | |||
| 98 | + | msg = fmt.Sprintf("Update %s available", result.LatestVersion) | |||
| 99 | + | } else { | |||
| 100 | + | msg = fmt.Sprintf("Update %s available (manual install required)", result.LatestVersion) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // Dispatch to GTK main thread. | |||
| 104 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 105 | + | c.toaster.Show(ctx, msg, component.ToastInfo) | |||
| 106 | + | return false | |||
| 107 | + | }) | |||
| 108 | + | glib.IdleAdd(&cb, 0) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // downloadAsync downloads and stages the update in the background. | |||
| 112 | + | func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL string) { | |||
| 113 | + | log := logging.FromContext(ctx) | |||
| 114 | + | ||||
| 115 | + | c.status = entity.UpdateStatusDownloading | |||
| 116 | + | ||||
| 117 | + | result, err := c.applyUC.Execute(ctx, usecase.ApplyUpdateInput{ | |||
| 118 | + | DownloadURL: downloadURL, | |||
| 119 | + | }) | |||
| 120 | + | if err != nil { | |||
| 121 | + | log.Error().Err(err).Msg("update download failed") | |||
| 122 | + | c.status = entity.UpdateStatusFailed | |||
| 123 | + | c.showToast(ctx, "Update download failed", component.ToastError) | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | if result.Status == entity.UpdateStatusReady { | |||
| 128 | + | c.status = entity.UpdateStatusReady | |||
| 129 | + | log.Info().Msg("update ready, will apply on exit") | |||
| 130 | + | c.showToast(ctx, "Update ready - applies on exit", component.ToastSuccess) | |||
| 131 | + | } else { | |||
| 132 | + | c.status = entity.UpdateStatusFailed | |||
| 133 | + | log.Warn().Str("message", result.Message).Msg("update staging failed") | |||
| 134 | + | c.showToast(ctx, result.Message, component.ToastWarning) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // showToast dispatches a toast notification to the GTK main thread. | |||
| 139 | + | func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) { | |||
| 140 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 141 | + | c.toaster.Show(ctx, msg, level) | |||
| 142 | + | return false | |||
| 143 | + | }) | |||
| 144 | + | glib.IdleAdd(&cb, 0) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // FinalizeOnExit applies any staged update during shutdown. | |||
| 148 | + | // This should be called from the app's shutdown handler. | |||
| 149 | + | func (c *UpdateCoordinator) FinalizeOnExit(ctx context.Context) error { | |||
| 150 | + | if c.applyUC == nil { | |||
| 151 | + | return nil | |||
| 152 | + | } | |||
| 153 | + | return c.applyUC.FinalizeOnExit(ctx) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // Status returns the current update status. | |||
| 157 | + | func (c *UpdateCoordinator) Status() entity.UpdateStatus { | |||
| 158 | + | return c.status | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // LastCheckResult returns the result of the last update check. | |||
| 162 | + | func (c *UpdateCoordinator) LastCheckResult() *usecase.CheckUpdateOutput { | |||
| 163 | + | return c.lastInfo | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // HasPendingUpdate returns true if an update is staged for exit. | |||
| 167 | + | func (c *UpdateCoordinator) HasPendingUpdate(ctx context.Context) bool { | |||
| 168 | + | if c.applyUC == nil { | |||
| 169 | + | return false | |||
| 170 | + | } | |||
| 171 | + | return c.applyUC.HasPendingUpdate(ctx) | |||
| 172 | + | } | |||
M internal/ui/deps.go +4 -0
| @@ -61,6 +61,10 @@ type Dependencies struct { | |||
| 61 | 61 | SessionRepo repository.SessionRepository | |
| 62 | 62 | CurrentSessionID entity.SessionID | |
| 63 | 63 | SnapshotUC *usecase.SnapshotSessionUseCase | |
| 64 | + | ||
| 65 | + | // Update management | |
| 66 | + | CheckUpdateUC *usecase.CheckUpdateUseCase | |
| 67 | + | ApplyUpdateUC *usecase.ApplyUpdateUseCase | |
| 64 | 68 | } | |
| 65 | 69 | ||
| 66 | 70 | // Validate checks that all required dependencies are set. | |
| @@ -61,6 +61,10 @@ type Dependencies struct { | |||||
| 61 | SessionRepo repository.SessionRepository | 61 | SessionRepo repository.SessionRepository | ||
| 62 | CurrentSessionID entity.SessionID | 62 | CurrentSessionID entity.SessionID | ||
| 63 | SnapshotUC *usecase.SnapshotSessionUseCase | 63 | SnapshotUC *usecase.SnapshotSessionUseCase | ||
| 64 | + | ||||
| 65 | + | // Update management | |||
| 66 | + | CheckUpdateUC *usecase.CheckUpdateUseCase | |||
| 67 | + | ApplyUpdateUC *usecase.ApplyUpdateUseCase | |||
| 64 | } | 68 | } | ||
| 65 | 69 | ||||
| 66 | // Validate checks that all required dependencies are set. | 70 | // Validate checks that all required dependencies are set. | ||