package main // launch_icon.go:抓取启动台应用 / 网站图标。 // 优先读项目目录常见 favicon/logo;否则请求本机端口页面解析 /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)]+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 里的 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 }