294 lines
7.1 KiB
Go
294 lines
7.1 KiB
Go
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
|
||
}
|