Files
code-utils/launch_icon.go
2026-08-15 17:18:00 +08:00

294 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// launch_icon.go抓取启动台应用 / 网站图标。
// 优先读项目目录常见 favicon/logo否则请求本机端口页面解析 <link rel=icon> /favicon.ico。
// 结果存为 dataURL仅本地 launch_apps.icon不同步
import (
"encoding/base64"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
const launchIconMaxBytes = 256 << 10 // 256KB
var (
launchIconClient = &http.Client{Timeout: 4 * time.Second}
iconHrefRe = regexp.MustCompile(`(?i)<link[^>]+rel=["'](?:shortcut\s+)?icon["'][^>]*>`)
iconHrefAttrRe = regexp.MustCompile(`(?i)href=["']([^"']+)["']`)
lpIconCacheMu sync.Mutex
lpIconCache = map[string]string{} // key=port|dir -> dataURL
)
var launchIconCandidates = []string{
"favicon.ico", "favicon.png", "favicon.svg",
"apple-touch-icon.png", "apple-touch-icon.ico",
"logo.svg", "logo.png", "logo.ico",
"public/favicon.ico", "public/favicon.png", "public/favicon.svg",
"public/logo.png", "public/logo.svg",
"src/favicon.ico", "src/favicon.png", "src/assets/favicon.ico",
"src/assets/logo.svg", "src/assets/logo.png",
"static/favicon.ico", "assets/favicon.ico",
}
func mimeFromExt(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".ico":
return "image/x-icon"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".svg":
return "image/svg+xml"
case ".webp":
return "image/webp"
default:
return "application/octet-stream"
}
}
func mimeFromHTTP(ct, path string) string {
ct = strings.TrimSpace(strings.Split(ct, ";")[0])
if strings.HasPrefix(ct, "image/") {
return ct
}
return mimeFromExt(path)
}
func bytesToDataURL(mime string, raw []byte) string {
if mime == "" || mime == "application/octet-stream" {
mime = "image/png"
}
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw)
}
func readIconFile(path string) (string, error) {
st, e := os.Stat(path)
if e != nil || st.IsDir() || st.Size() <= 0 || st.Size() > launchIconMaxBytes {
return "", errors.New("ICON_NOT_FOUND")
}
raw, e := os.ReadFile(path)
if e != nil || len(raw) == 0 {
return "", errors.New("ICON_NOT_FOUND")
}
return bytesToDataURL(mimeFromExt(path), raw), nil
}
func iconFromDir(dir string) string {
dir = strings.TrimSpace(dir)
if dir == "" {
return ""
}
for _, rel := range launchIconCandidates {
if u, e := readIconFile(filepath.Join(dir, filepath.FromSlash(rel))); e == nil && u != "" {
return u
}
}
return ""
}
func httpGetLimited(url string) (body []byte, ct string, err error) {
req, e := http.NewRequest(http.MethodGet, url, nil)
if e != nil {
return nil, "", e
}
req.Header.Set("User-Agent", "code-count-launchpad/1.0")
resp, e := launchIconClient.Do(req)
if e != nil {
return nil, "", e
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", errors.New("ICON_HTTP_STATUS")
}
raw, e := io.ReadAll(io.LimitReader(resp.Body, launchIconMaxBytes+1))
if e != nil || len(raw) == 0 || len(raw) > launchIconMaxBytes {
return nil, "", errors.New("ICON_TOO_LARGE")
}
return raw, resp.Header.Get("Content-Type"), nil
}
func resolveIconURL(base string, href string) string {
href = strings.TrimSpace(href)
if href == "" {
return ""
}
if strings.HasPrefix(href, "data:image/") {
return href
}
if strings.HasPrefix(href, "//") {
return "http:" + href
}
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
return href
}
base = strings.TrimRight(base, "/")
if strings.HasPrefix(href, "/") {
// http://127.0.0.1:5173 + /favicon.ico
if i := strings.Index(base, "://"); i >= 0 {
rest := base[i+3:]
host := rest
if j := strings.Index(rest, "/"); j >= 0 {
host = rest[:j]
}
return base[:i+3] + host + href
}
}
return base + "/" + strings.TrimPrefix(href, "./")
}
func iconFromHTML(base string, html []byte) string {
m := iconHrefRe.FindAll(html, -1)
for _, tag := range m {
am := iconHrefAttrRe.FindSubmatch(tag)
if len(am) < 2 {
continue
}
href := resolveIconURL(base, string(am[1]))
if strings.HasPrefix(href, "data:image/") {
return href
}
if href == "" {
continue
}
raw, ct, e := httpGetLimited(href)
if e != nil {
continue
}
return bytesToDataURL(mimeFromHTTP(ct, href), raw)
}
return ""
}
func iconFromPort(port int) string {
if port <= 0 || port > 65535 {
return ""
}
bases := []string{
"http://127.0.0.1:" + itoa(port),
"http://localhost:" + itoa(port),
}
for _, base := range bases {
// 1) 直接 favicon
for _, path := range []string{"/favicon.ico", "/favicon.png", "/apple-touch-icon.png"} {
raw, ct, e := httpGetLimited(base + path)
if e == nil && len(raw) > 4 {
return bytesToDataURL(mimeFromHTTP(ct, path), raw)
}
}
// 2) 首页 HTML 里的 <link rel=icon>
raw, ct, e := httpGetLimited(base + "/")
if e == nil && (strings.Contains(ct, "html") || strings.Contains(string(raw[:minInt(200, len(raw))]), "<")) {
if u := iconFromHTML(base, raw); u != "" {
return u
}
}
}
return ""
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [16]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func cacheKey(port int, dir string) string {
return itoa(port) + "|" + strings.ToLower(filepath.Clean(dir))
}
func peekCachedLaunchIcon(port int, dir string) string {
lpIconCacheMu.Lock()
defer lpIconCacheMu.Unlock()
return lpIconCache[cacheKey(port, dir)]
}
// resolveLaunchIcon 按目录 → 端口顺序解析图标;带短时内存缓存。
func resolveLaunchIcon(port int, dir string) string {
key := cacheKey(port, dir)
lpIconCacheMu.Lock()
if u, ok := lpIconCache[key]; ok {
lpIconCacheMu.Unlock()
return u
}
lpIconCacheMu.Unlock()
u := iconFromDir(dir)
if u == "" {
u = iconFromPort(port)
}
if u != "" {
lpIconCacheMu.Lock()
lpIconCache[key] = u
lpIconCacheMu.Unlock()
}
return u
}
func (s *Store) setLaunchIcon(id int64, icon string) error {
_, e := s.db.Exec(`UPDATE launch_apps SET icon=?,updated_at=? WHERE id=?`, icon, nowRFC(), id)
return e
}
// FetchLaunchIcon 主动抓取并写回已保存应用的图标port/dir 可覆盖配置。
func (a *App) FetchLaunchIcon(id int64) (string, error) {
if e := a.ready(); e != nil {
return "", e
}
app, e := a.store.GetLaunchApp(id)
if e != nil {
return "", errors.New("LAUNCH_APP_NOT_FOUND")
}
// 清缓存后重抓
lpIconCacheMu.Lock()
delete(lpIconCache, cacheKey(app.Port, app.Dir))
lpIconCacheMu.Unlock()
u := resolveLaunchIcon(app.Port, app.Dir)
if u == "" {
return "", errors.New("ICON_NOT_FOUND")
}
if e := a.store.setLaunchIcon(app.ID, u); e != nil {
return "", e
}
a.emit("launchpad:changed", nil)
return u, nil
}
// PeekLaunchIcon 不落库,仅预览(扫描条目 / 新建表单用)。
func (a *App) PeekLaunchIcon(port int, dir string) (string, error) {
if e := a.ready(); e != nil {
return "", e
}
u := resolveLaunchIcon(port, dir)
if u == "" {
return "", errors.New("ICON_NOT_FOUND")
}
return u, nil
}