1package usecase
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/logging"
12)
13
14const (
15 // File permission for directories.
16 updateDirPerm = 0o755
17)
18
19// ApplyUpdateInput holds the input for the apply update use case.
20type ApplyUpdateInput struct {
21 // DownloadURL is the URL to download the update from.
22 DownloadURL string
23}
24
25// ApplyUpdateOutput holds the result of the update application.
26type ApplyUpdateOutput struct {
27 // Status is the current update status.
28 Status entity.UpdateStatus
29 // Message is a human-readable status message.
30 Message string
31 // StagedPath is the path to the staged binary (if status is Ready).
32 StagedPath string
33}
34
35// ApplyUpdateUseCase downloads and stages updates for application on exit.
36type ApplyUpdateUseCase struct {
37 downloader port.UpdateDownloader
38 applier port.UpdateApplier
39 cacheDir string
40}
41
42// NewApplyUpdateUseCase creates a new apply update use case.
43func NewApplyUpdateUseCase(
44 downloader port.UpdateDownloader,
45 applier port.UpdateApplier,
46 cacheDir string,
47) *ApplyUpdateUseCase {
48 return &ApplyUpdateUseCase{
49 downloader: downloader,
50 applier: applier,
51 cacheDir: cacheDir,
52 }
53}
54
55// Execute downloads and stages the update for application on exit.
56func (uc *ApplyUpdateUseCase) Execute(ctx context.Context, input ApplyUpdateInput) (*ApplyUpdateOutput, error) {
57 log := logging.FromContext(ctx)
58
59 // Check if we can self-update.
60 if !uc.applier.CanSelfUpdate(ctx) {
61 reason := uc.applier.SelfUpdateBlockedReason(ctx)
62 var message string
63 switch reason {
64 case port.SelfUpdateBlockedFlatpak:
65 message = "Cannot auto-update: installed via Flatpak (use 'flatpak update' instead)"
66 case port.SelfUpdateBlockedPacman:
67 message = "Cannot auto-update: installed via pacman/AUR (use your package manager instead)"
68 default:
69 message = "Cannot auto-update: binary is not writable"
70 }
71 return &ApplyUpdateOutput{
72 Status: entity.UpdateStatusFailed,
73 Message: message,
74 }, nil
75 }
76
77 // Create download directory.
78 downloadDir := filepath.Join(uc.cacheDir, "updates")
79 if err := os.MkdirAll(downloadDir, updateDirPerm); err != nil {
80 return nil, fmt.Errorf("failed to create download directory: %w", err)
81 }
82
83 log.Info().Str("url", input.DownloadURL).Msg("downloading update")
84
85 // Download the archive.
86 archivePath, err := uc.downloader.Download(ctx, input.DownloadURL, downloadDir)
87 if err != nil {
88 return &ApplyUpdateOutput{
89 Status: entity.UpdateStatusFailed,
90 Message: fmt.Sprintf("Download failed: %v", err),
91 }, nil
92 }
93
94 log.Info().Str("archive", archivePath).Msg("extracting update")
95
96 // Extract the binary.
97 binaryPath, err := uc.downloader.Extract(ctx, archivePath, downloadDir)
98 if err != nil {
99 _ = os.Remove(archivePath)
100 return &ApplyUpdateOutput{
101 Status: entity.UpdateStatusFailed,
102 Message: fmt.Sprintf("Extraction failed: %v", err),
103 }, nil
104 }
105
106 log.Info().Str("binary", binaryPath).Msg("staging update")
107
108 // Stage the update.
109 if err := uc.applier.StageUpdate(ctx, binaryPath); err != nil {
110 _ = os.Remove(archivePath)
111 _ = os.Remove(binaryPath)
112 return &ApplyUpdateOutput{
113 Status: entity.UpdateStatusFailed,
114 Message: fmt.Sprintf("Staging failed: %v", err),
115 }, nil
116 }
117
118 // Clean up download artifacts.
119 _ = os.Remove(archivePath)
120 _ = os.Remove(binaryPath)
121
122 log.Info().Msg("update staged successfully, will apply on exit")
123
124 return &ApplyUpdateOutput{
125 Status: entity.UpdateStatusReady,
126 Message: "Update ready - will apply on exit",
127 }, nil
128}
129
130// FinalizeOnExit applies the staged update during shutdown.
131// This should be called from the app's shutdown handler.
132func (uc *ApplyUpdateUseCase) FinalizeOnExit(ctx context.Context) error {
133 log := logging.FromContext(ctx)
134
135 if !uc.applier.HasStagedUpdate(ctx) {
136 log.Debug().Msg("no staged update to apply")
137 return nil
138 }
139
140 backupPath, err := uc.applier.ApplyOnExit(ctx)
141 if err != nil {
142 log.Error().Err(err).Msg("failed to apply update on exit")
143 return err
144 }
145
146 log.Info().
147 Str("backup", backupPath).
148 Msg("update applied successfully")
149
150 return nil
151}
152
153// HasPendingUpdate checks if there's an update waiting to be applied on exit.
154func (uc *ApplyUpdateUseCase) HasPendingUpdate(ctx context.Context) bool {
155 return uc.applier.HasStagedUpdate(ctx)
156}
157
158// ClearPendingUpdate removes any staged update.
159func (uc *ApplyUpdateUseCase) ClearPendingUpdate(ctx context.Context) error {
160 return uc.applier.ClearStagedUpdate(ctx)
161}