v0.23.2 bfd5eb4

applier.go RawHistoryFolder

1package updater
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8
9	"golang.org/x/sys/unix"
10
11	"github.com/bnema/dumber/internal/application/port"
12	"github.com/bnema/dumber/internal/infrastructure/config"
13	"github.com/bnema/dumber/internal/infrastructure/env"
14	"github.com/bnema/dumber/internal/logging"
15)
16
17const (
18	// Staging directory name within XDG_STATE_HOME.
19	stagingDirName = "pending-update"
20	// Staged binary filename.
21	stagedBinaryName = "dumber"
22	// Backup suffix for old binary.
23	backupSuffix = ".old"
24	// File permission for directories and executables.
25	applierExecPerm = 0o755
26	// Execute permission bit for owner.
27	execBit = 0o100
28)
29
30// Applier implements UpdateApplier for self-updating the binary.
31type Applier struct {
32	stateDir string
33}
34
35// NewApplier creates a new update applier.
36// stateDir is the XDG state directory (e.g., ~/.local/state/dumber).
37func NewApplier(stateDir string) *Applier {
38	return &Applier{
39		stateDir: stateDir,
40	}
41}
42
43// NewApplierFromXDG creates a new update applier using XDG directories.
44func NewApplierFromXDG() (*Applier, error) {
45	stateDir, err := config.GetStateDir()
46	if err != nil {
47		return nil, fmt.Errorf("failed to get state directory: %w", err)
48	}
49	return NewApplier(stateDir), nil
50}
51
52// stagingDir returns the path to the staging directory.
53func (a *Applier) stagingDir() string {
54	return filepath.Join(a.stateDir, stagingDirName)
55}
56
57// stagedBinaryPath returns the path to the staged binary.
58func (a *Applier) stagedBinaryPath() string {
59	return filepath.Join(a.stagingDir(), stagedBinaryName)
60}
61
62// CanSelfUpdate checks if the current binary is writable by the current user.
63// Returns false if running inside a Flatpak sandbox (updates handled by Flatpak)
64// or if installed via pacman/AUR (updates handled by package manager).
65func (a *Applier) CanSelfUpdate(ctx context.Context) bool {
66	log := logging.FromContext(ctx)
67
68	// Flatpak sandboxed apps should not self-update; Flatpak handles updates.
69	if env.IsFlatpak() {
70		log.Debug().Msg("running in Flatpak sandbox, self-update disabled")
71		return false
72	}
73
74	// Pacman/AUR packages should not self-update; use pacman or AUR helper.
75	if env.IsPacman() {
76		log.Debug().Msg("installed via pacman/AUR, self-update disabled")
77		return false
78	}
79
80	binaryPath, err := a.GetBinaryPath()
81	if err != nil {
82		log.Debug().Err(err).Msg("failed to get binary path")
83		return false
84	}
85
86	// Check if we have write permission on the binary file.
87	err = unix.Access(binaryPath, unix.W_OK)
88	if err != nil {
89		log.Debug().
90			Str("path", binaryPath).
91			Err(err).
92			Msg("binary is not writable, self-update disabled")
93		return false
94	}
95
96	log.Debug().Str("path", binaryPath).Msg("binary is writable, self-update enabled")
97	return true
98}
99
100// SelfUpdateBlockedReason returns why self-update is blocked, or empty if allowed.
101func (a *Applier) SelfUpdateBlockedReason(ctx context.Context) port.SelfUpdateBlockedReason {
102	log := logging.FromContext(ctx)
103
104	// Flatpak sandboxed apps should not self-update; Flatpak handles updates.
105	if env.IsFlatpak() {
106		log.Debug().Msg("self-update blocked: running in Flatpak sandbox")
107		return port.SelfUpdateBlockedFlatpak
108	}
109
110	// Pacman/AUR packages should not self-update; use pacman or AUR helper.
111	if env.IsPacman() {
112		log.Debug().Msg("self-update blocked: installed via pacman/AUR")
113		return port.SelfUpdateBlockedPacman
114	}
115
116	binaryPath, err := a.GetBinaryPath()
117	if err != nil {
118		log.Debug().Err(err).Msg("self-update blocked: failed to get binary path")
119		return port.SelfUpdateBlockedNotWritable
120	}
121
122	// Check if we have write permission on the binary file.
123	if err := unix.Access(binaryPath, unix.W_OK); err != nil {
124		log.Debug().Str("path", binaryPath).Err(err).Msg("self-update blocked: binary not writable")
125		return port.SelfUpdateBlockedNotWritable
126	}
127
128	return port.SelfUpdateAllowed
129}
130
131// GetBinaryPath returns the path to the currently running binary.
132func (*Applier) GetBinaryPath() (string, error) {
133	path, err := os.Executable()
134	if err != nil {
135		return "", fmt.Errorf("failed to get executable path: %w", err)
136	}
137
138	// Resolve any symlinks to get the actual binary path.
139	resolved, err := filepath.EvalSymlinks(path)
140	if err != nil {
141		return "", fmt.Errorf("failed to resolve symlinks: %w", err)
142	}
143
144	return resolved, nil
145}
146
147// StageUpdate copies the new binary to the staging directory.
148func (a *Applier) StageUpdate(ctx context.Context, newBinaryPath string) error {
149	log := logging.FromContext(ctx)
150
151	// Create staging directory.
152	stagingDir := a.stagingDir()
153	if err := os.MkdirAll(stagingDir, applierExecPerm); err != nil {
154		return fmt.Errorf("failed to create staging directory: %w", err)
155	}
156
157	// Read the new binary.
158	newBinary, err := os.ReadFile(newBinaryPath)
159	if err != nil {
160		return fmt.Errorf("failed to read new binary: %w", err)
161	}
162
163	// Write to staging location.
164	stagedPath := a.stagedBinaryPath()
165	if err := os.WriteFile(stagedPath, newBinary, applierExecPerm); err != nil {
166		return fmt.Errorf("failed to write staged binary: %w", err)
167	}
168
169	log.Info().
170		Str("staged_path", stagedPath).
171		Int("size", len(newBinary)).
172		Msg("update staged for exit")
173
174	return nil
175}
176
177// HasStagedUpdate checks if there is an update staged and ready to apply.
178func (a *Applier) HasStagedUpdate(_ context.Context) bool {
179	stagedPath := a.stagedBinaryPath()
180	info, err := os.Stat(stagedPath)
181	if err != nil {
182		return false
183	}
184	// Ensure it's a regular file with execute permission.
185	return info.Mode().IsRegular() && info.Mode()&execBit != 0
186}
187
188// ApplyOnExit replaces the current binary with the staged update.
189// This should be called during graceful shutdown.
190func (a *Applier) ApplyOnExit(ctx context.Context) (string, error) {
191	log := logging.FromContext(ctx)
192
193	if !a.HasStagedUpdate(ctx) {
194		return "", fmt.Errorf("no staged update found")
195	}
196
197	binaryPath, err := a.GetBinaryPath()
198	if err != nil {
199		return "", err
200	}
201
202	stagedPath := a.stagedBinaryPath()
203	backupPath := binaryPath + backupSuffix
204
205	log.Info().
206		Str("current", binaryPath).
207		Str("staged", stagedPath).
208		Str("backup", backupPath).
209		Msg("applying staged update")
210
211	// Step 1: Remove old backup if exists.
212	_ = os.Remove(backupPath)
213
214	// Step 2: Rename current binary to backup.
215	if err := os.Rename(binaryPath, backupPath); err != nil {
216		return "", fmt.Errorf("failed to backup current binary: %w", err)
217	}
218
219	// Step 3: Rename staged binary to current.
220	if err := os.Rename(stagedPath, binaryPath); err != nil {
221		// Try to restore backup.
222		if restoreErr := os.Rename(backupPath, binaryPath); restoreErr != nil {
223			log.Error().Err(restoreErr).Msg("failed to restore backup after update failure")
224		}
225		return "", fmt.Errorf("failed to install new binary: %w", err)
226	}
227
228	// Step 4: Clean up staging directory.
229	_ = os.RemoveAll(a.stagingDir())
230
231	log.Info().
232		Str("binary", binaryPath).
233		Str("backup", backupPath).
234		Msg("update applied successfully")
235
236	return backupPath, nil
237}
238
239// ClearStagedUpdate removes any staged update without applying it.
240func (a *Applier) ClearStagedUpdate(ctx context.Context) error {
241	log := logging.FromContext(ctx)
242
243	stagingDir := a.stagingDir()
244	if err := os.RemoveAll(stagingDir); err != nil {
245		return fmt.Errorf("failed to clear staged update: %w", err)
246	}
247
248	log.Debug().Str("path", stagingDir).Msg("cleared staged update")
249	return nil
250}

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