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
|
|||
|
|
}
|