Files
magpie/internal/plugin/updates.go
T
yetone 456208945b plugins: a plugin can be installed from a git repository — github:owner/repo, owner/repo, a GitHub URL or a git+https:// one (Lemon on Discord: can plugins be installed directly from git?) — magpie plugin add and the Plugins field took only an npm name or a path, and turned a git spec away as "isn't an npm package name". A git spec now goes to bun add as given and is kept as given in plugins.json; the package it installs is named by its repository's package.json, which bun records as the dependency whose spec it is, so the plugin is known (list, on/off, rm, its version, its README) by that name or by the spec. update and the row's Update run bun update on it, which fetches the repository's commit now where bun add keeps the one bun.lock has, the spec unchanged; npm is never asked for a newer version of it, nor auto-updates it. Adding the same package from another URL, or from npm, takes its entry's place. The Plugins field says it takes a GitHub repo (en+zh); an installed git plugin shows its package's name, an Update button, and a page with its README, no npm link and a Source link to the repository. Nothing that installed before is turned away. TestGitPlugin (a fake bun fetching from local bare repositories, the real one loading the plugin when on PATH) and plugin-git.test.cjs fail without the change; TestIsGit checks what counts as a git spec. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; plugin-git, plugin-local, plugin-market, plugin-updates, click-scroll, old-webkit pass.
2026-10-02 02:24:49 +08:00

221 lines
6.4 KiB
Go

package plugin
// Plugin updates, as magpie keeps itself up to date: the community's
// plugins (@magpie-community/*, the ones the built-in subscriptions move
// onto) update by themselves, a little after magpie starts and every few
// hours; anyone else's new version waits for the reader, who sees a dot
// on Plugins and updates it with a click. A plugin pinned to a version
// stays on it. Updating never cuts a reply streaming through a plugin:
// the host it runs on finishes it (see Restart).
import (
"context"
"encoding/json"
"log"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/settings"
"github.com/yetone/magpie/internal/steady"
"github.com/yetone/magpie/internal/update"
)
// Updates is what magpie's last look at npm found for the plugins.
type Updates struct {
Checked time.Time `json:"checked"`
// Waiting are the plugins with a newer version that magpie leaves to
// the reader: someone else's, or one pinned to a version.
Waiting []Waiting `json:"waiting"`
// Updated are the plugins magpie updated by itself, the latest last.
Updated []Updated `json:"updated"`
}
// Waiting is a plugin with a newer version on npm than the one installed.
type Waiting struct {
Spec string `json:"spec"`
Package string `json:"package"`
Version string `json:"version"`
Latest string `json:"latest"`
}
// Updated is a plugin magpie updated by itself.
type Updated struct {
Package string `json:"package"`
From string `json:"from"`
To string `json:"to"`
At time.Time `json:"at"`
}
const (
// updateEvery is how often magpie looks for plugin updates
updateEvery = 6 * time.Hour
// keepUpdated is how many updates magpie remembers making
keepUpdated = 20
)
// Official is whether the package is the community's, magpie's own,
// which magpie keeps up to date by itself.
func Official(pkg string) bool { return strings.HasPrefix(pkg, "@magpie-community/") }
// Pinned is whether spec names a version (or a range or a tag other than
// latest): the user chose it, and it stays. A git one's version is its
// repository's, not npm's: it is never pinned, and never asked of npm.
func Pinned(spec string) bool {
if IsPath(spec) || IsGit(spec) {
return false
}
v := strings.TrimPrefix(spec, Name(spec))
return v != "" && v != "@latest"
}
var (
updatesMu sync.Mutex
// latestOf is the version npm has of each package, "" for one it
// didn't tell (tests stub it)
latestOf = func(ctx context.Context, names []string) map[string]string {
out := map[string]string{}
for n, i := range Info(ctx, names) {
out[n] = i.Version
}
return out
}
// installLatest installs the package's newest version (tests stub it)
installLatest = func(ctx context.Context, pkg string) error { return install(ctx, pkg+"@latest") }
)
func updatesPath() string { return filepath.Join(settings.Dir(), "plugin-updates.json") }
func readUpdates() Updates {
var u Updates
if b, err := steady.ReadFile(updatesPath()); err == nil {
_ = json.Unmarshal(b, &u)
}
return u
}
// PendingUpdates is what the last look found, less what has changed since:
// a plugin updated, removed or switched off since waits no more.
func PendingUpdates() Updates {
updatesMu.Lock()
defer updatesMu.Unlock()
u := readUpdates()
have := map[string]Entry{}
for _, e := range Load().Plugins {
have[Name(e.Spec)] = e
}
u.Waiting = slices.DeleteFunc(u.Waiting, func(w Waiting) bool {
e, ok := have[w.Package]
return !ok || e.Off || !update.Newer(w.Latest, Installed(e.Spec))
})
if u.Waiting == nil {
u.Waiting = []Waiting{}
}
if u.Updated == nil {
u.Updated = []Updated{}
}
return u
}
// LastUpdated is the update magpie made by itself to the package since
// the time given, if it made one.
func LastUpdated(pkg string, since time.Time) (Updated, bool) {
u := PendingUpdates()
for i := len(u.Updated) - 1; i >= 0; i-- {
if x := u.Updated[i]; x.Package == pkg && x.At.After(since) {
return x, true
}
}
return Updated{}, false
}
// CheckUpdates asks npm for each plugin's newest version, updates the
// community's (unpinned, switched on) to it and notes the others' as
// waiting. The plugins updated are loaded again, a reply streaming
// through the old ones finishing first.
func CheckUpdates(ctx context.Context) (Updates, error) {
var es []Entry
var names []string
for _, e := range Load().Plugins {
// a git one's package may be on npm too, as someone else's
if !IsPath(e.Spec) && !IsGit(e.Spec) {
es = append(es, e)
names = append(names, Name(e.Spec))
}
}
latest := map[string]string{}
if len(names) > 0 {
latest = latestOf(ctx, names)
}
var waiting []Waiting
var made []Updated
var errs []error
for _, e := range es {
pkg, have, now := Name(e.Spec), Installed(e.Spec), latest[Name(e.Spec)]
if e.Off || have == "" || now == "" || !update.Newer(now, have) {
continue
}
if !Official(pkg) || Pinned(e.Spec) {
waiting = append(waiting, Waiting{Spec: e.Spec, Package: pkg, Version: have, Latest: now})
continue
}
if err := installLatest(ctx, pkg); err != nil {
errs = append(errs, err)
log.Printf("updating the plugin %s: %s", pkg, err)
waiting = append(waiting, Waiting{Spec: e.Spec, Package: pkg, Version: have, Latest: now})
continue
}
v := Installed(e.Spec)
if v == "" {
v = now
}
made = append(made, Updated{Package: pkg, From: have, To: v, At: time.Now().UTC().Truncate(time.Second)})
log.Printf("updated the plugin %s from %s to %s", pkg, have, v)
}
updatesMu.Lock()
u := readUpdates()
u.Checked = time.Now().UTC().Truncate(time.Second)
u.Waiting = waiting
u.Updated = append(u.Updated, made...)
if n := len(u.Updated); n > keepUpdated {
u.Updated = u.Updated[n-keepUpdated:]
}
if b, err := json.MarshalIndent(u, "", " "); err == nil {
if err := os.MkdirAll(settings.Dir(), 0o700); err == nil {
_ = writeWhole(updatesPath(), b)
}
}
updatesMu.Unlock()
if len(made) > 0 {
Restart()
}
if len(errs) > 0 {
return u, errs[0]
}
return u, nil
}
// KeepUpdated looks for plugin updates a little after magpie starts, then
// every updateEvery, run by the magpie serving the gateway (one magpie,
// never two at once).
func KeepUpdated(ctx context.Context) {
t := time.NewTimer(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
if len(Load().Plugins) > 0 {
cctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
_, _ = CheckUpdates(cctx)
cancel()
}
t.Reset(updateEvery)
}
}