[mike@maginot]

This commit is contained in:
2026-08-10 20:13:52 +02:00
parent 659ab03652
commit d26a008763
4 changed files with 214 additions and 22 deletions
+148 -12
View File
@@ -34,17 +34,144 @@ import (
// ------------------------------------------------------------ Konfiguration
var selfUpdate = selfUpdater{
repo: "https://git.micw.org/mike/dx",
asset: "dx",
current: version, // aus main.go, per -ldflags gesetzt
verify: []string{"--version"},
repo: "https://git.micw.org/mike/dx",
asset: "dx",
current: version, // aus main.go, per -ldflags gesetzt
verify: []string{"--version"},
every: 24 * time.Hour,
quietEnv: "DX_NO_UPDATE_CHECK",
}
type selfUpdater struct {
repo string // Repo-URL wie im Browser: https://host/owner/repo
asset string // Basisname der Assets, "-<goos>-<goarch>" kommt dazu
current string // laufende Version
verify []string // Probelauf des Downloads; leer lässt ihn ausfallen
repo string // Repo-URL wie im Browser: https://host/owner/repo
asset string // Basisname der Assets, "-<goos>-<goarch>" kommt dazu
current string // laufende Version
verify []string // Probelauf des Downloads; leer lässt ihn ausfallen
every time.Duration // Abstand der Nachschau von selbst; 0 schaltet sie ab
quietEnv string // diese Umgebungsvariable gesetzt: auch dann Ruhe
}
// updateRefreshFlag ist die Option, mit der das Programm sich selbst im
// Hintergrund aufruft. Sie steht bewusst nicht in der Hilfe.
const updateRefreshFlag = "--update-refresh"
// ------------------------------------------------------- Nachschau von selbst
// daily ist der Anschluss für den gewöhnlichen Programmlauf. Sie kostet nichts:
// im Vordergrund wird nie das Netz angefasst. Zurück kommt die Zeile, die auf
// eine neue Version hinweist — oder "", wenn es nichts zu sagen gibt; wie sie
// aussieht, entscheidet der Aufrufer. Ist der Merkzettel älter als `every`,
// stößt sie nebenbei einen Hintergrundlauf an, dessen Antwort der nächste
// Aufruf vorfindet.
func (u selfUpdater) daily() string {
if u.every <= 0 || os.Getenv(u.quietEnv) != "" || !updateOnTerminal() {
return ""
}
st := u.loadState() // keine Datei: Nullwert, also sofort fällig
if time.Since(st.Checked) >= u.every {
// Der Zeitstempel wandert weiter, bevor gefragt wird, nicht danach:
// sonst starten zwei gleichzeitige Läufe zwei Abfragen, und ein Server,
// der gerade nicht mag, bekäme bei jedem Aufruf eine neue. Bleibt der
// Merkzettel nicht liegen, wird auch nicht gefragt — sonst hinge an
// einem unbeschreibbaren Cache-Verzeichnis ein Prozess je Aufruf.
st.Checked = time.Now()
if u.saveState(st) == nil {
u.spawnRefresh()
}
}
if st.Latest == "" || updateCompare(st.Latest, u.current) <= 0 {
return ""
}
return fmt.Sprintf("%s %s is available, run '%s --update'", u.asset, st.Latest, u.asset)
}
// refresh ist der Hintergrundlauf: fragen, notieren, still bleiben. Das
// Notieren erledigt latest, misslingt die Abfrage, bleibt der alte Stand.
func (u selfUpdater) refresh() {
_, _ = u.latest()
}
// spawnRefresh ruft dieses Programm noch einmal auf, nur zum Fragen, und wartet
// nicht. Ohne Wait wird das Kind beim Ende dieses Prozesses von init
// übernommen — es lebt also länger als der Aufruf, und dessen Ausgabe bleibt
// davon unberührt.
func (u selfUpdater) spawnRefresh() {
exe, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(exe, updateRefreshFlag)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil // alles nach /dev/null
if cmd.Start() == nil {
cmd.Process.Release()
}
}
// Der Hinweis gilt dem Menschen davor. Läuft das Programm in einer Pipe, in
// einem Skript oder unter cron, wird weder gefragt noch etwas gesagt.
func updateOnTerminal() bool {
st, err := os.Stderr.Stat()
return err == nil && st.Mode()&os.ModeCharDevice != 0
}
// ------------------------------------------------------------- Merkzettel
// updateState ist das, was zwischen zwei Aufrufen übrig bleibt: wann zuletzt
// gefragt wurde und was dabei herauskam.
type updateState struct {
Checked time.Time `json:"checked"`
Latest string `json:"latest"`
}
// Der Merkzettel liegt im Cache-Verzeichnis, nicht in der Konfiguration: geht
// er verloren, wird eben einmal zu früh gefragt.
func (u selfUpdater) statePath() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(dir, u.asset, "update.json"), nil
}
func (u selfUpdater) loadState() updateState {
var st updateState
path, err := u.statePath()
if err != nil {
return st
}
b, err := os.ReadFile(path)
if err != nil {
return st
}
json.Unmarshal(b, &st) // eine kaputte Datei zählt wie keine
return st
}
func (u selfUpdater) saveState(st updateState) error {
path, err := u.statePath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
b, err := json.Marshal(st)
if err != nil {
return err
}
// Über eine Nebendatei, damit ein gleichzeitiger Lauf nie ein halbes JSON
// vorfindet.
tmp := path + ".new"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// ------------------------------------------------------------------ Ablauf
@@ -139,7 +266,7 @@ func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode) (str
}
tmp := f.Name()
resp, err := updateGet(a.URL)
resp, err := updateGet(context.Background(), a.URL)
if err != nil {
f.Close()
os.Remove(tmp)
@@ -225,7 +352,12 @@ func (u selfUpdater) latest() (updateRelease, error) {
if err != nil {
return updateRelease{}, err
}
resp, err := updateGet(base + "/releases/latest")
// Die Frage ist klein; hängt sie, hängt sie nicht lange. Das großzügige
// Zeitlimit von updateClient gilt dem Download.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := updateGet(ctx, base+"/releases/latest")
if err != nil {
return updateRelease{}, err
}
@@ -238,6 +370,10 @@ func (u selfUpdater) latest() (updateRelease, error) {
if rel.TagName == "" {
return updateRelease{}, fmt.Errorf("%s has no releases", u.repo)
}
// Jede geglückte Frage füllt den Merkzettel — egal ob sie von --update,
// --check-update oder dem Hintergrundlauf kam.
u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName})
return rel, nil
}
@@ -261,8 +397,8 @@ func (u selfUpdater) apiBase() (string, error) {
// aber nicht ewig.
var updateClient = &http.Client{Timeout: 5 * time.Minute}
func updateGet(target string) (*http.Response, error) {
req, err := http.NewRequest(http.MethodGet, target, nil)
func updateGet(ctx context.Context, target string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, err
}