From ab76cce91bd4eec74f58ff4fd29437daa78f19a4 Mon Sep 17 00:00:00 2001
From: yetone
Date: Sun, 27 Sep 2026 18:52:49 +0800
Subject: [PATCH] =?UTF-8?q?providers:=20a=20custom=20provider's=20icon=20c?=
=?UTF-8?q?an=20come=20from=20its=20website=20(#12)=20=E2=80=94=20From=20t?=
=?UTF-8?q?he=20website,=20beside=20Choose=20a=20picture,=20finds=20the=20?=
=?UTF-8?q?site=20behind=20the=20base=20URL=20(its=20host,=20then=20the=20?=
=?UTF-8?q?domains=20it=20sits=20under:=20api.deepseek.com,=20then=20deeps?=
=?UTF-8?q?eek.com)=20and=20keeps=20the=20icon=20its=20home=20page=20names?=
=?UTF-8?q?=20(an=20SVG=20first,=20then=20the=20apple-touch-icon,=20data:?=
=?UTF-8?q?=20URIs=20too)=20or=20its=20/favicon.ico,=20like=20a=20picture?=
=?UTF-8?q?=20picked=20by=20hand;=20only=20public=20https=20hosts=20are=20?=
=?UTF-8?q?reached.=20Icons=20are=20fetched=20as=20magpie,=20since=20deeps?=
=?UTF-8?q?eek.com=20turns=20Go's=20own=20User-Agent=20away?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
internal/gui/assets/app.js | 21 +++-
internal/gui/assets/i18n.js | 3 +
internal/gui/providers.go | 14 +++
internal/provider/favicon.go | 184 ++++++++++++++++++++++++++++++
internal/provider/favicon_test.go | 119 +++++++++++++++++++
internal/provider/icon.go | 1 +
6 files changed, 341 insertions(+), 1 deletion(-)
create mode 100644 internal/provider/favicon.go
create mode 100644 internal/provider/favicon_test.go
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index fb3c3a68..2c59cde5 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -2040,9 +2040,28 @@ function iconPicker(ed) {
};
const choose = el("button", "text", t("Choose a picture…"));
choose.onclick = () => file.click();
+ // the site's own icon, looked for from the base URL typed above
+ const site = el("button", "text", t("From the website"));
+ site.title = t("Look for the icon of the site the base URL is on");
+ site.onclick = async () => {
+ const base = draft[apiField[draft.api]] || draft.chat || draft.responses || draft.anthropic || "";
+ site.disabled = true;
+ site.textContent = t("Looking…");
+ try {
+ const data = await api("icons/favicon", { url: base });
+ draft.icon = data.icon;
+ editorError("");
+ draw();
+ syncHead();
+ } catch (e) {
+ editorError(e.message);
+ site.disabled = false;
+ site.textContent = t("From the website");
+ }
+ };
const builtin = el("button", "text", t("Built-in icons"));
builtin.onclick = () => { open = !open; draw(); };
- box.append(file, choose, builtin);
+ box.append(file, choose, site, builtin);
if (draft.icon && draft.icon !== "generic") {
const reset = el("button", "text", t("Default"));
reset.onclick = () => { draft.icon = ""; draw(); syncHead(); };
diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js
index dc2914e9..e229adfe 100644
--- a/internal/gui/assets/i18n.js
+++ b/internal/gui/assets/i18n.js
@@ -135,6 +135,9 @@ const I18N = {
"Icon": "图标",
"Choose a picture…": "选择图片…",
"Built-in icons": "内置图标",
+ "From the website": "从网站获取",
+ "Look for the icon of the site the base URL is on": "按上面填的 Base URL 找到对应网站的图标",
+ "Looking…": "查找中…",
"what {agent} ships with": "{agent} 自带的默认值",
"current value": "当前值",
"use as typed": "按输入使用",
diff --git a/internal/gui/providers.go b/internal/gui/providers.go
index 6e048ace..d52de5fa 100644
--- a/internal/gui/providers.go
+++ b/internal/gui/providers.go
@@ -353,6 +353,20 @@ func providerRoutes(mux *http.ServeMux, w Windows) {
}
writeJSON(rw, map[string]string{"icon": icon})
})
+ // the site's own icon, found from the provider's base URL (#12)
+ mux.HandleFunc("POST /api/icons/favicon", func(rw http.ResponseWriter, r *http.Request) {
+ var in struct{ URL string }
+ if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&in); err != nil {
+ fail(rw, err)
+ return
+ }
+ icon, err := provider.FaviconFor(r.Context(), in.URL)
+ if err != nil {
+ fail(rw, err)
+ return
+ }
+ writeJSON(rw, map[string]string{"icon": icon})
+ })
mux.HandleFunc("GET /api/icons/{name}", func(rw http.ResponseWriter, r *http.Request) {
f := provider.IconFile(r.PathValue("name"))
if f == "" {
diff --git a/internal/provider/favicon.go b/internal/provider/favicon.go
new file mode 100644
index 00000000..20a50418
--- /dev/null
+++ b/internal/provider/favicon.go
@@ -0,0 +1,184 @@
+package provider
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// FaviconFor finds the site behind a provider's base URL and keeps its icon
+// like a picture picked by hand (#12). The API's own host is asked first,
+// then the domains it sits under (api.deepseek.com, then deepseek.com),
+// since an API host seldom has a page of its own. On each, the home page's
+// pictures come before /favicon.ico. Only public https
+// hosts are reached, as for an import link's icon.
+func FaviconFor(ctx context.Context, base string) (string, error) {
+ sites, err := faviconSites(base)
+ if err != nil {
+ return "", err
+ }
+ return favicon(ctx, guardClient(), sites, iconURL)
+}
+
+// faviconSites are the https origins to look on for base's icon: its host
+// with its port, then each domain above it down to two labels.
+func faviconSites(base string) ([]string, error) {
+ u, err := url.Parse(strings.TrimSpace(base))
+ if err != nil || u.Hostname() == "" {
+ return nil, errors.New("type the provider's base URL first: its site's icon is looked for there")
+ }
+ host := u.Hostname()
+ if local(host) {
+ return nil, errors.New("the base URL is on this computer or the local network; its icon can't be fetched")
+ }
+ sites := []string{"https://" + u.Host}
+ if host != u.Host {
+ sites = append(sites, "https://"+host)
+ }
+ if strings.Trim(host, "0123456789.") != "" && !strings.Contains(host, ":") { // not an IP
+ labels := strings.Split(host, ".")
+ for i := 1; len(labels)-i >= 2; i++ {
+ sites = append(sites, "https://"+strings.Join(labels[i:], "."))
+ }
+ }
+ return sites, nil
+}
+
+// favicon tries each site in turn; check vets a picture's URL before it is
+// fetched (iconURL; tests pass one that lets a local server through).
+func favicon(ctx context.Context, c *http.Client, sites []string, check func(string) (string, error)) (string, error) {
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+ var last error
+ for _, site := range sites {
+ cands := append(pageIcons(ctx, c, site), site+"/favicon.ico")
+ for _, cand := range cands {
+ if data, ok := strings.CutPrefix(cand, "data:"); ok {
+ if b := dataURI(data); b != nil {
+ if icon, err := StoreIcon(b); err == nil {
+ return icon, nil
+ }
+ }
+ continue
+ }
+ u, err := check(cand)
+ if err != nil {
+ last = err
+ continue
+ }
+ icon, err := fetchIcon(ctx, c, u)
+ if err == nil {
+ return icon, nil
+ }
+ last = err
+ }
+ if ctx.Err() != nil {
+ break
+ }
+ }
+ if last == nil {
+ last = errors.New("no icon found")
+ }
+ return "", errorf("couldn't find the site's icon (%s): %v", strings.Join(sites, ", "), last)
+}
+
+// iconUA names magpie to the sites icons are fetched from: some (deepseek.com)
+// turn Go's own User-Agent away with a 429.
+const iconUA = "magpie"
+
+var (
+ linkTag = regexp.MustCompile(`(?is)]*>`)
+ tagAttr = regexp.MustCompile(`(?is)\b(rel|href|sizes|type)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))`)
+ headDone = regexp.MustCompile(`(?i)|`)
+ pages := map[string]string{}
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
+ if p, ok := pages[r.Host]; ok && r.URL.Path == "/" {
+ rw.Write([]byte(p))
+ return
+ }
+ switch r.URL.Path {
+ case "/static/touch.png", "/favicon.ico":
+ if strings.HasPrefix(r.Host, "bare.") || r.URL.Path == "/static/touch.png" {
+ rw.Write(png)
+ return
+ }
+ case "/logo.svg":
+ rw.Write(svg)
+ return
+ }
+ // a site that answers every path with its page, the icon's too
+ rw.Write([]byte("
not found"))
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+ port := srv.URL[strings.LastIndex(srv.URL, ":"):]
+ // every site is the test server, told apart by the Host it is asked as
+ c := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
+ var d net.Dialer
+ return d.DialContext(ctx, network, srv.Listener.Addr().String())
+ }}}
+ site := func(h string) string { return "http://" + h + port }
+ pass := func(s string) (string, error) { return s, nil }
+ stored := func(icon string, want []byte) {
+ t.Helper()
+ b, err := os.ReadFile(IconFile(strings.TrimPrefix(icon, iconPrefix)))
+ if err != nil || string(b) != string(want) {
+ t.Errorf("stored %q: %q %v, want %q", icon, b, err, want)
+ }
+ }
+
+ // an SVG link is taken before the touch icon, whatever the order
+ pages["svg.test"+port] = ``
+ icon, err := favicon(context.Background(), c, []string{site("svg.test")}, pass)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stored(icon, svg)
+
+ // a relative apple-touch-icon, in single quotes and odd case
+ pages["touch.test"+port] = ``
+ if icon, err = favicon(context.Background(), c, []string{site("touch.test")}, pass); err != nil {
+ t.Fatal(err)
+ }
+ stored(icon, png)
+
+ // a data: URI icon (#12)
+ pages["data.test"+port] = ``
+ if icon, err = favicon(context.Background(), c, []string{site("data.test")}, pass); err != nil {
+ t.Fatal(err)
+ }
+ stored(icon, svg)
+
+ // the API host has nothing; the domain above it has /favicon.ico
+ if icon, err = favicon(context.Background(), c, []string{site("api.none"), site("bare.test")}, pass); err != nil {
+ t.Fatal(err)
+ }
+ stored(icon, png)
+
+ // a page answered for the icon is not a picture: nothing is found
+ if _, err = favicon(context.Background(), c, []string{site("api.none")}, pass); err == nil {
+ t.Error("an HTML page was kept as the icon")
+ }
+ // a link in the body is not the page's icon
+ pages["body.test"+port] = ``
+ if _, err = favicon(context.Background(), c, []string{site("body.test")}, pass); err == nil {
+ t.Error("a link in the body was taken")
+ }
+}
diff --git a/internal/provider/icon.go b/internal/provider/icon.go
index 7a9a370c..cb844378 100644
--- a/internal/provider/icon.go
+++ b/internal/provider/icon.go
@@ -173,6 +173,7 @@ func fetchIcon(ctx context.Context, c *http.Client, u string) (string, error) {
return "", err
}
req.Header.Set("Accept", "image/*")
+ req.Header.Set("User-Agent", iconUA)
res, err := c.Do(req)
if err != nil {
return "", errorf("could not fetch the icon: %v", err)