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)