detached 5a26d21

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:
3333 - id: default
3434 name_template: >-
3535 {{ .ProjectName }}_
36- {{- .Version }}_
3736 {{- .Os }}_
3837 {{- if eq .Arch "amd64" }}x86_64
3938 {{- else if eq .Arch "386" }}i386
4039 {{- else }}{{ .Arch }}{{ end }}
40+ wrap_in_directory: "{{ .ProjectName }}_{{ .Version }}"
4141 format_overrides:
4242 - goos: linux
43- format: tar.gz
43+ formats: ['tar.gz']
4444 files:
4545 - LICENSE
4646 - README.md
@@ -109,9 +109,16 @@ release:
109109
110110 ### Linux (x86_64/amd64)
111111 ```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"
115122 ```
116123
117124 ### Dependencies
@@ -33,14 +33,14 @@ archives:
33 - id: default33 - id: default
34 name_template: >-34 name_template: >-
35 {{ .ProjectName }}_35 {{ .ProjectName }}_
36- {{- .Version }}_
37 {{- .Os }}_36 {{- .Os }}_
38 {{- if eq .Arch "amd64" }}x86_6437 {{- if eq .Arch "amd64" }}x86_64
39 {{- else if eq .Arch "386" }}i38638 {{- 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: linux42 - goos: linux
43- format: tar.gz43+ formats: ['tar.gz']
44 files:44 files:
45 - LICENSE45 - LICENSE
46 - README.md46 - README.md
@@ -109,9 +109,16 @@ release:
109 109
110 ### Linux (x86_64/amd64)110 ### Linux (x86_64/amd64)
111 ```bash111 ```bash
112- wget https://github.com/bnema/dumber/releases/download/{{.Tag}}/dumber_{{.Version}}_linux_x86_64.tar.gz112+ # Download latest release
113- tar -xzf dumber_{{.Version}}_linux_x86_64.tar.gz113+ 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 ### Dependencies124 ### Dependencies
M CHANGELOG.md +7 -0
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### 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.
815 - **Session management & resurrection**: Zellij-inspired session management system allowing users to save, list, and restore browser sessions.
916 - **Automatic session snapshots**: Debounced state saving (configurable interval, default 5s) captures tabs, panes, splits, stacks, URLs, titles, and zoom levels.
1017 - **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 ### Added7 ### 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 (
2525 "github.com/bnema/dumber/internal/infrastructure/idle"
2626 "github.com/bnema/dumber/internal/infrastructure/media"
2727 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
28+ "github.com/bnema/dumber/internal/infrastructure/updater"
2829 "github.com/bnema/dumber/internal/logging"
2930 "github.com/bnema/dumber/internal/ui"
3031 "github.com/bnema/dumber/internal/ui/theme"
@@ -118,7 +119,13 @@ func runGUI() int {
118119 repos := createRepositories(db)
119120
120121 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)
122129
123130 // Auto-restore: find last restorable session if enabled and no explicit restore requested
124131 if cfg.Session.AutoRestore && restoreSessionID == "" {
@@ -283,11 +290,13 @@ type useCases struct {
283290 copyURL *usecase.CopyURLUseCase
284291 snapshot *usecase.SnapshotSessionUseCase
285292 lastRestorable *usecase.GetLastRestorableSessionUseCase
293+ checkUpdate *usecase.CheckUpdateUseCase
294+ applyUpdate *usecase.ApplyUpdateUseCase
286295 clipboard port.Clipboard
287296 favicon *favicon.Service
288297 }
289298
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 {
291300 const idAlphabetSize = 26
292301 idCounter := uint64(0)
293302 idGenerator := func() string {
@@ -297,11 +306,17 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string) *u
297306
298307 clipboardAdapter := clipboard.New()
299308 faviconCacheDir, _ := config.GetFaviconCacheDir()
309+ cacheDir, _ := config.GetXDGDirs()
300310 defaultZoom := cfg.DefaultWebpageZoom
301311
302312 // Create LRU cache for zoom levels to avoid database queries on repeat visits
303313 zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
304314
315+ // Create update infrastructure
316+ updateChecker := updater.NewGitHubChecker()
317+ updateDownloader := updater.NewGitHubDownloader()
318+ updateApplier := updater.NewApplier(stateDir)
319+
305320 return &useCases{
306321 tabs: usecase.NewManageTabsUseCase(idGenerator),
307322 panes: usecase.NewManagePanesUseCase(idGenerator),
@@ -312,6 +327,8 @@ func createUseCases(repos *repositories, cfg *config.Config, stateDir string) *u
312327 copyURL: usecase.NewCopyURLUseCase(clipboardAdapter),
313328 snapshot: usecase.NewSnapshotSessionUseCase(repos.sessionState),
314329 lastRestorable: usecase.NewGetLastRestorableSessionUseCase(repos.session, repos.sessionState, stateDir),
330+ checkUpdate: usecase.NewCheckUpdateUseCase(updateChecker, updateApplier, buildInfo),
331+ applyUpdate: usecase.NewApplyUpdateUseCase(updateDownloader, updateApplier, cacheDir.CacheHome),
315332 clipboard: clipboardAdapter,
316333 favicon: favicon.NewService(faviconCacheDir),
317334 }
@@ -356,5 +373,7 @@ func buildUIDependencies(
356373 SessionStateRepo: repos.sessionState,
357374 CurrentSessionID: currentSessionID,
358375 SnapshotUC: uc.snapshot,
376+ CheckUpdateUC: uc.checkUpdate,
377+ ApplyUpdateUC: uc.applyUpdate,
359378 }
360379 }
@@ -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 requested130 // 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.CopyURLUseCase290 copyURL *usecase.CopyURLUseCase
284 snapshot *usecase.SnapshotSessionUseCase291 snapshot *usecase.SnapshotSessionUseCase
285 lastRestorable *usecase.GetLastRestorableSessionUseCase292 lastRestorable *usecase.GetLastRestorableSessionUseCase
293+ checkUpdate *usecase.CheckUpdateUseCase
294+ applyUpdate *usecase.ApplyUpdateUseCase
286 clipboard port.Clipboard295 clipboard port.Clipboard
287 favicon *favicon.Service296 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 = 26300 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.DefaultWebpageZoom310 defaultZoom := cfg.DefaultWebpageZoom
301 311
302 // Create LRU cache for zoom levels to avoid database queries on repeat visits312 // 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 @@
11 package cmd
22
33 import (
4+ "context"
45 "fmt"
56
67 "github.com/spf13/cobra"
78
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/cli"
811 "github.com/bnema/dumber/internal/cli/styles"
12+ "github.com/bnema/dumber/internal/infrastructure/updater"
13+ "github.com/bnema/dumber/internal/logging"
914 )
1015
1116 var aboutCmd = &cobra.Command{
@@ -29,5 +34,37 @@ func runAbout(_ *cobra.Command, _ []string) error {
2934 output := renderer.Render(app.BuildInfo)
3035
3136 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+
3269 return nil
3370 }
@@ -1,11 +1,16 @@
1 package cmd1 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 nil69 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 {
241241 ZoomCacheSize: defaultZoomCacheSize,
242242 WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,
243243 },
244+ Update: UpdateConfig{
245+ EnableOnStartup: true, // Check for updates on startup by default
246+ AutoDownload: false, // Conservative: don't auto-download by default
247+ },
244248 }
245249 }
246250
@@ -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() {
315315 m.setMediaDefaults(defaults)
316316 m.setRuntimeDefaults(defaults)
317317 m.setSessionDefaults(defaults)
318+ m.setUpdateDefaults(defaults)
318319 }
319320
320321 func (m *Manager) setHistoryDefaults(defaults *Config) {
@@ -442,6 +443,11 @@ func (m *Manager) setSessionDefaults(defaults *Config) {
442443 m.viper.SetDefault("session.session_mode.actions", defaults.Session.SessionMode.Actions)
443444 }
444445
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+
445451 // New returns a new default configuration instance.
446452 // This is a convenience function for getting default config without the full manager.
447453 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 {
3131 Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"`
3232 // Performance holds internal performance tuning options (not exposed in UI).
3333 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"`
3436 }
3537
3638 // RenderingMode selects GPU vs CPU rendering.
@@ -280,6 +282,17 @@ type DebugConfig struct {
280282 EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"`
281283 }
282284
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+
283296 // WorkspaceConfig captures layout, pane, and tab behavior preferences.
284297 type WorkspaceConfig struct {
285298 // 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 {
9090 sessionManager *component.SessionManager
9191 snapshotService *snapshot.Service
9292
93+ // Update management
94+ updateCoord *coordinator.UpdateCoordinator
95+
9396 // ID generator for tabs/panes
9497 idCounter uint64
9598 idMu sync.Mutex
@@ -191,6 +194,7 @@ func (a *App) onActivate(ctx context.Context) {
191194 a.initFindBarConfig()
192195 a.initSessionManager(ctx)
193196 a.initSnapshotService(ctx)
197+ a.initUpdateCoordinator(ctx)
194198 a.createInitialTab(ctx)
195199 a.finalizeActivation(ctx)
196200 }
@@ -476,6 +480,27 @@ func (a *App) initSnapshotService(ctx context.Context) {
476480 log.Debug().Int("interval_ms", intervalMs).Msg("snapshot service started")
477481 }
478482
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+
479504 // GetTabList implements port.TabListProvider.
480505 func (a *App) GetTabList() *entity.TabList {
481506 return a.tabs
@@ -616,6 +641,13 @@ func (a *App) onShutdown(ctx context.Context) {
616641 }
617642 }
618643
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+
619651 // Cancel context to signal all goroutines
620652 a.cancel(errors.New("application shutdown"))
621653
@@ -90,6 +90,9 @@ type App struct {
90 sessionManager *component.SessionManager90 sessionManager *component.SessionManager
91 snapshotService *snapshot.Service91 snapshotService *snapshot.Service
92 92
93+ // Update management
94+ updateCoord *coordinator.UpdateCoordinator
95+
93 // ID generator for tabs/panes96 // ID generator for tabs/panes
94 idCounter uint6497 idCounter uint64
95 idMu sync.Mutex98 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.tabs506 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 goroutines651 // 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 {
6161 SessionRepo repository.SessionRepository
6262 CurrentSessionID entity.SessionID
6363 SnapshotUC *usecase.SnapshotSessionUseCase
64+
65+ // Update management
66+ CheckUpdateUC *usecase.CheckUpdateUseCase
67+ ApplyUpdateUC *usecase.ApplyUpdateUseCase
6468 }
6569
6670 // Validate checks that all required dependencies are set.
@@ -61,6 +61,10 @@ type Dependencies struct {
61 SessionRepo repository.SessionRepository61 SessionRepo repository.SessionRepository
62 CurrentSessionID entity.SessionID62 CurrentSessionID entity.SessionID
63 SnapshotUC *usecase.SnapshotSessionUseCase63 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close