v0.23.1 6c872d5

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

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