feat(ui): 接入 vendor 真实图标库 + 图标选择器
- assets/icons/ 共 80+ 张真实品牌图标嵌入 UI 包 - entry 新增 IconKey 字段,持久化到 config.Entry.IconName - iconPickerDialog 网格布局图标选择器 - Add 完成后自动弹出图标选择器,Skip 则用 vendor 占位图 - ⋯ 菜单新增 "Change icon..." 项 - vendorIconFor 解析顺序:catalog → vendor 占位 → qr 兜底
This commit is contained in:
+88
-16
@@ -5,20 +5,88 @@ import (
|
||||
"embed"
|
||||
"image"
|
||||
_ "image/png"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
)
|
||||
|
||||
//go:embed assets/vendor_icons/*.png assets/app_icon.png
|
||||
//go:embed assets/vendor_icons/*.png assets/icons/*.png assets/app_icon.png
|
||||
var assetsFS embed.FS
|
||||
|
||||
var (
|
||||
// iconCatalog holds all decoded brand-icons keyed by stem (filename
|
||||
// without ".png"). Populated once on first access.
|
||||
iconCatalog map[string]image.Image
|
||||
iconCatalogOnce sync.Once
|
||||
iconSortedKeys []string
|
||||
)
|
||||
|
||||
// loadIconCatalog decodes every PNG from assets/icons/ into iconCatalog.
|
||||
// Also populates iconSortedKeys for the icon picker.
|
||||
func loadIconCatalog() {
|
||||
const fn = "internal.ui.loadIconCatalog"
|
||||
iconCatalog = map[string]image.Image{}
|
||||
|
||||
dir, err := assetsFS.ReadDir("assets/icons")
|
||||
if err != nil {
|
||||
global.Log.WithField("func", fn).WithError(err).Warn("read icons dir failed")
|
||||
return
|
||||
}
|
||||
|
||||
var keys []string
|
||||
for _, entry := range dir {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".png") {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSuffix(entry.Name(), ".png")
|
||||
data, err := assetsFS.ReadFile("assets/icons/" + entry.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
global.Log.WithField("func", fn).WithError(err).
|
||||
WithField("file", entry.Name()).Warn("icon decode failed")
|
||||
continue
|
||||
}
|
||||
iconCatalog[key] = img
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
iconSortedKeys = keys
|
||||
global.Log.WithField("func", fn).WithField("count", len(keys)).Debug("icons loaded")
|
||||
}
|
||||
|
||||
// allIconKeys returns the sorted list of available icon keys for the
|
||||
// icon picker. Safe to call before icons are loaded — triggers load.
|
||||
func allIconKeys() []string {
|
||||
iconCatalogOnce.Do(loadIconCatalog)
|
||||
return iconSortedKeys
|
||||
}
|
||||
|
||||
// iconByKey returns the named icon or nil. Safe to call before icons are
|
||||
// loaded — triggers load.
|
||||
func iconByKey(key string) image.Image {
|
||||
iconCatalogOnce.Do(loadIconCatalog)
|
||||
return iconCatalog[key]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Original vendor-icon fallbacks (assets/vendor_icons/). These are simple
|
||||
// placeholder squares with a single initial — kept so old entries without an
|
||||
// IconKey still render something recognisable.
|
||||
|
||||
// vendorIcons holds the decoded vendor PNGs keyed by the authenticator
|
||||
// Name() return value. Built once at first lookup so we pay the decode
|
||||
// cost once per process instead of per frame.
|
||||
// Name() return value. Built once at first lookup.
|
||||
var vendorIcons = map[string]image.Image{}
|
||||
|
||||
func loadVendorIcons() {
|
||||
const fn = "internal.ui.loadVendorIcons"
|
||||
func ensureVendorIcons() {
|
||||
const fn = "internal.ui.ensureVendorIcons"
|
||||
if len(vendorIcons) > 0 {
|
||||
return
|
||||
}
|
||||
files := []string{"google", "microsoft", "okta", "hotp", "battlenet", "steam", "qr"}
|
||||
for _, name := range files {
|
||||
data, err := assetsFS.ReadFile("assets/vendor_icons/" + name + ".png")
|
||||
@@ -37,24 +105,28 @@ func loadVendorIcons() {
|
||||
}
|
||||
}
|
||||
|
||||
// vendorIconFor returns the icon image bound to the given authenticator
|
||||
// vendor key ("google", "microsoft", ...). Falls back to the generic
|
||||
// "qr" tile when no specific icon is registered, and nil when even that
|
||||
// is unavailable (only happens if the embed is broken).
|
||||
func vendorIconFor(vendor string) image.Image {
|
||||
if len(vendorIcons) == 0 {
|
||||
loadVendorIcons()
|
||||
// vendorIconFor returns the icon image for the given key. Resolution
|
||||
// order:
|
||||
// 1. iconCatalog (full brand icons from assets/icons/)
|
||||
// 2. vendorIcons (fallback placeholders from assets/vendor_icons/)
|
||||
// 3. vendorIcons["qr"] (generic fallback)
|
||||
//
|
||||
// This preserves backward compatibility: callers that pass a vendor name
|
||||
// ("google", "steam", …) still get their placeholder.
|
||||
func vendorIconFor(key string) image.Image {
|
||||
iconCatalogOnce.Do(loadIconCatalog)
|
||||
ensureVendorIcons()
|
||||
if img, ok := iconCatalog[key]; ok {
|
||||
return img
|
||||
}
|
||||
if img, ok := vendorIcons[vendor]; ok {
|
||||
if img, ok := vendorIcons[key]; ok {
|
||||
return img
|
||||
}
|
||||
return vendorIcons["qr"]
|
||||
}
|
||||
|
||||
// AppIcon returns the decoded application icon, or nil if the asset is
|
||||
// missing. Currently unused at runtime — Gio v0.7 has no API to set a
|
||||
// window icon, so this exists so callers (e.g. a future tray bucket)
|
||||
// can reuse the same bitmap.
|
||||
// missing.
|
||||
func AppIcon() image.Image {
|
||||
data, err := assetsFS.ReadFile("assets/app_icon.png")
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user