plugins: the GitHub list waits up to 3s for a repository's https icon fetched the first time (yetone: 非官方插件显示不出来 logo)

RepoIcons starts every fetch, then waits for those not in yet, so the
list's first answer has them; one slower than the wait shows the next
time. The fetch takes fetchPluginIcon before its goroutine starts.

Ran: go test ./internal/provider ./internal/gui; the new test -race
-count=10, and red with a 1ns wait. A sandbox build (fresh HOME, real
GitHub): the first /api/plugins/github answer has all 8 icons the 13
tagged repositories give, screenshot in light and dark.
This commit is contained in:
yetone committed 2026-10-10 05:22:07 +08:00
1 parent 3e5bc3d8fe
commit 52f6b3a3ab
3 files changed
+121 -10

No files matched your search

+5 -1
View File
@@ -281,8 +281,12 @@ func pluginRoutes(mux *http.ServeMux, w Windows) {
// each one's own picture as the page can show it, kept here as an
// installed plugin's is (a data URI isn't sent on to the page)
repos := append([]plugin.Tagged(nil), plugin.TaggedRepos(r.Context())...)
said := make([]string, len(repos))
for i := range repos {
repos[i].Icon = provider.RepoIcon(repos[i].Icon)
said[i] = repos[i].Icon
}
for i, ic := range provider.RepoIcons(said, 3*time.Second) {
repos[i].Icon = ic
}
writeJSON(rw, map[string]any{"repos": repos, "topic": plugin.Topic})
})
+59 -9
View File
@@ -32,6 +32,8 @@ var plugIcons struct {
from string // the file they were read from
kept map[string]string
tried map[string]time.Time
// the URLs being fetched now, each closed once its fetch is over
busy map[string]chan struct{}
}
// fetchPluginIcon fetches an https picture as an import link's is: a
@@ -98,24 +100,72 @@ func pluginOwnIcon(said string) string {
if err != nil {
return ""
}
if plugIcons.busy == nil {
plugIcons.busy = map[string]chan struct{}{}
}
done := make(chan struct{})
plugIcons.busy[k] = done
fetch := fetchPluginIcon
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ic, err := fetchPluginIcon(ctx, u)
if err != nil {
return
}
ic, err := fetch(ctx, u)
plugIcons.Lock()
keepPlugIcon(k, ic)
if err == nil {
keepPlugIcon(k, ic)
}
delete(plugIcons.busy, k)
plugIcons.Unlock()
close(done)
}()
return ""
}
// RepoIcon is the Icon value of the picture a plugin not installed yet
// gives in its package.json (a repository tagged magpie-plugin), checked
// and kept as an installed plugin's is: "" while there is none to show.
func RepoIcon(said string) string { return pluginOwnIcon(said) }
// RepoIcons are the Icon values of the pictures plugins not installed yet
// give in their package.json (repositories tagged magpie-plugin), checked
// and kept as an installed plugin's are: "" where there is none to show.
// An https picture not fetched before is waited for up to wait, so the
// list's first answer has it (yetone: 非官方插件显示不出来 logo); one
// slower than that shows the next time the list is asked.
func RepoIcons(said []string, wait time.Duration) []string {
out := make([]string, len(said))
var busy []chan struct{}
for i, s := range said {
out[i] = pluginOwnIcon(s)
if out[i] == "" && s != "" {
sum := sha256.Sum256([]byte(s))
plugIcons.Lock()
if ch := plugIcons.busy[hex.EncodeToString(sum[:16])]; ch != nil {
busy = append(busy, ch)
}
plugIcons.Unlock()
}
}
if len(busy) == 0 {
return out
}
t := time.NewTimer(wait)
defer t.Stop()
for _, ch := range busy {
select {
case <-ch:
case <-t.C:
return refill(out, said)
}
}
return refill(out, said)
}
// refill asks again for the pictures not in yet, which those fetched since
// now have
func refill(out, said []string) []string {
for i, s := range said {
if out[i] == "" && s != "" {
out[i] = pluginOwnIcon(s)
}
}
return out
}
// keptPluginIcons are the names of the stored pictures plugins gave, which
// pruneIcons leaves.
+57
View File
@@ -94,3 +94,60 @@ func TestPluginOwnIcon(t *testing.T) {
t.Errorf("fetched again: %d", fetched.Load())
}
}
// yetone: 非官方插件显示不出来 logo. A repository's https picture not fetched
// before is waited for, so the GitHub list's first answer has it; one slower
// than the wait shows the next time, and the wait ends when it does.
func TestRepoIconsWaitForFirstFetch(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
png := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")
slow := make(chan struct{})
// the fetch left waiting ends with the test
t.Cleanup(func() {
close(slow)
for i := 0; i < 200; i++ {
plugIcons.Lock()
n := len(plugIcons.busy)
plugIcons.Unlock()
if n == 0 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Error("the slow fetch didn't end")
})
was := fetchPluginIcon
t.Cleanup(func() { fetchPluginIcon = was })
fetchPluginIcon = func(ctx context.Context, u string) (string, error) {
if strings.Contains(u, "slow") {
select {
case <-slow:
case <-ctx.Done():
}
return "", ctx.Err()
}
time.Sleep(100 * time.Millisecond)
return StoreIcon(png)
}
data := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
said := []string{"https://gaccode.example/favicon.png", "", data}
got := RepoIcons(said, 5*time.Second)
if !strings.HasPrefix(got[0], "file:") || got[1] != "" || !strings.HasPrefix(got[2], "file:") {
t.Fatalf("first answer = %q", got)
}
start := time.Now()
got = RepoIcons([]string{"https://slow.example/icon.png"}, 300*time.Millisecond)
if got[0] != "" {
t.Fatalf("a fetch never finished = %q", got[0])
}
if d := time.Since(start); d < 250*time.Millisecond || d > 3*time.Second {
t.Fatalf("waited %v for a 300ms wait", d)
}
}