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
}