diff --git a/internal/gui/plugins.go b/internal/gui/plugins.go index dd8643a5..2bf4560c 100644 --- a/internal/gui/plugins.go +++ b/internal/gui/plugins.go @@ -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}) }) diff --git a/internal/provider/plugin_icon.go b/internal/provider/plugin_icon.go index a75028b5..87b04d43 100644 --- a/internal/provider/plugin_icon.go +++ b/internal/provider/plugin_icon.go @@ -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. diff --git a/internal/provider/plugin_icon_test.go b/internal/provider/plugin_icon_test.go index 31a0e1c4..cf99e984 100644 --- a/internal/provider/plugin_icon_test.go +++ b/internal/provider/plugin_icon_test.go @@ -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) + } +}