diff --git a/README.md b/README.md index 37d465c..6d6f52d 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,38 @@ entweder die alte Datei oder die neue, nie eine halbe. Liegt das Binary irgendwo, wo der eigene Benutzer nicht schreiben darf (`/usr/local/bin`), sagt `dx --update` das und tut nichts — dann `sudo`. +### Einmal am Tag von selbst + +Ohne Zutun sieht `dx` einmal am Tag nach und sagt danach am Ende der Ausgabe auf +stderr Bescheid: + +``` +dx 2.1.9 is available, run 'dx --update' +``` + +Der Lauf im Vordergrund fasst dafür nie das Netz an. Er liest nur einen +Merkzettel — `~/Library/Caches/dx/update.json`, unter Linux +`~/.cache/dx/update.json` — und wenn der älter als einen Tag ist, startet er +nebenbei `dx --update-refresh`: denselben Läufer noch einmal, abgekoppelt, ohne +Ausgabe, nur zum Fragen. Auf dessen Antwort wartet niemand, sie steht beim +nächsten Aufruf im Merkzettel. `dx` bleibt damit genau so schnell wie vorher, +auch wenn der Server gerade schweigt. + +Der Zeitstempel wandert weiter, *bevor* gefragt wird. Zwei gleichzeitige Läufe +starten deshalb nur eine Abfrage, und ein Server, der nicht antwortet, wird +morgen wieder gefragt und nicht bei jedem Aufruf. Lässt sich der Merkzettel +nicht schreiben, unterbleibt die Frage ganz — sonst hinge an einem +schreibgeschützten Cache-Verzeichnis ein Prozess je Aufruf. + +Gefragt und gesagt wird nur, wenn stderr am Terminal hängt. In einer Pipe, in +einem Skript und unter cron ist Ruhe, und `DX_NO_UPDATE_CHECK=1` schaltet es +ganz ab. + +Das kostet Platz: `net/http` samt TLS macht aus einem 1,8-MB-Binary eins von +6,2 MB. Für ein Werkzeug, das ohnehin auf der Platte liegt, ist das der Preis +dafür, sich selbst erneuern zu können — wer ihn nicht zahlen will, lässt +`selfupdate.go` weg. + ## Wiederverwenden `selfupdate.go` hängt an nichts im Rest des Programms und kommt mit der @@ -81,18 +113,31 @@ oben anpassen — ```go var selfUpdate = selfUpdater{ - repo: "https://git.micw.org/mike/foo", - asset: "foo", - current: version, - verify: []string{"--version"}, + repo: "https://git.micw.org/mike/foo", + asset: "foo", + current: version, + verify: []string{"--version"}, + every: 24 * time.Hour, + quietEnv: "FOO_NO_UPDATE_CHECK", } ``` -— und `--update` und `--check-update` in die Optionen hängen. Alles Weitere in -der Datei heißt `selfUpdate…` oder `update…` und kollidiert deshalb nicht. -`verify` leer lassen, wenn das Programm keinen billigen Versionsaufruf hat; dann -entfällt der Probelauf. Erwartet wird eine Gitea-Instanz (getestet mit 1.27); -GitHub kennt dieselbe Route unter anderen Feldnamen und wird nicht bedient. +Dazu drei Zeilen im Optionenteil — `--update`, `--check-update` und das +versteckte `updateRefreshFlag` — und eine am Ende des Programms: + +```go +if hint := selfUpdate.daily(); hint != "" { + fmt.Fprintln(os.Stderr, hint) +} +``` + +`daily` liefert nur den Text; wie er aussieht, entscheidet das Programm. Alles +Weitere in der Datei heißt `selfUpdate…` oder `update…` und kollidiert deshalb +nicht. `verify` leer lassen, wenn das Programm keinen billigen Versionsaufruf +hat, dann entfällt der Probelauf; `every: 0` schaltet die Nachschau von selbst +ab und lässt nur die beiden Optionen übrig. Erwartet wird eine Gitea-Instanz +(getestet mit 1.27); GitHub kennt dieselbe Route unter anderen Feldnamen und +wird nicht bedient. ## Optionen diff --git a/main.go b/main.go index d2c858a..add301c 100644 --- a/main.go +++ b/main.go @@ -385,6 +385,9 @@ usage: dx [-n] [-d] [-r] [-t N] [path] --version --check-update look for a newer release --update download and install the newest release + +dx looks for a new release once a day, in the background, and says so on +stderr. DX_NO_UPDATE_CHECK=1 turns that off. `, version) } @@ -426,6 +429,9 @@ func main() { os.Exit(1) } return + case a == updateRefreshFlag: // der Hintergrundlauf, nicht in der Hilfe + selfUpdate.refresh() + return case a == "--number": o.byCount = true case a == "--dirs": @@ -507,6 +513,11 @@ func main() { if res.unreadbl > 0 { fmt.Fprintln(os.Stderr, cDark.Sprintf("dx: %d unreadable entries skipped", res.unreadbl)) } + // Kostet nichts: der Hinweis stammt aus dem Merkzettel, gefragt wird + // höchstens einmal am Tag und dann im Hintergrund. + if hint := selfUpdate.daily(); hint != "" { + fmt.Fprintln(os.Stderr, cDark.Sprint(hint)) + } } func mustNum(s string) int { diff --git a/selfupdate.go b/selfupdate.go index 4919929..8c52856 100644 --- a/selfupdate.go +++ b/selfupdate.go @@ -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, "--" 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, "--" 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 } diff --git a/version.txt b/version.txt index ebf14b4..8dbb0f2 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.1.8 +2.1.10