feat(ui): vendor 图标系统 + 应用图标占位
- 新增 tools/gen_icons 生成 7 张 vendor 占位 PNG + 256x256 应用图标 - internal/ui/vendor_icons.go 用 //go:embed 加载并按 Authenticator.Name() 查表 - entry 行圆环中心叠 16dp vendor 图标,HOTP 直接用图标占位 - 图标为通用色块 + 首字母,无第三方商标;用户可按文件名替换为真实 logo - assets/README 说明替换方式与 Windows .syso 嵌入步骤
@@ -0,0 +1,39 @@
|
||||
# UI assets
|
||||
|
||||
## vendor_icons/
|
||||
|
||||
Generated placeholder icons (64×64 PNG) used to identify each
|
||||
authenticator vendor in the entry list. They are intentionally generic —
|
||||
a coloured rounded square with a single white initial — so they carry no
|
||||
third-party trademark.
|
||||
|
||||
To use real vendor logos, replace any individual file in place. The
|
||||
filename is the lookup key (matches `authenticator.Authenticator.Name()`):
|
||||
|
||||
- `google.png`
|
||||
- `microsoft.png`
|
||||
- `okta.png`
|
||||
- `hotp.png`
|
||||
- `battlenet.png`
|
||||
- `steam.png`
|
||||
- `qr.png` — fallback for entries with an unknown vendor
|
||||
|
||||
The recommended size is 64×64 PNG with transparent corners. Files are
|
||||
embedded at build time via `//go:embed` in `internal/ui/vendor_icons.go`.
|
||||
|
||||
## app_icon.png
|
||||
|
||||
A 256×256 placeholder for the application window/tray icon. Gio v0.7 has
|
||||
no runtime API to set the window icon — for Windows you need to embed a
|
||||
`.ico` resource via `rsrc` or `goversioninfo` at build time:
|
||||
|
||||
```
|
||||
go install github.com/akavel/rsrc@latest
|
||||
rsrc -ico app.ico -o cmd/winauth/rsrc_windows.syso
|
||||
```
|
||||
|
||||
To regenerate the placeholders, run:
|
||||
|
||||
```
|
||||
go run ./tools/gen_icons
|
||||
```
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 370 B |
|
After Width: | Height: | Size: 382 B |
|
After Width: | Height: | Size: 348 B |
|
After Width: | Height: | Size: 359 B |
|
After Width: | Height: | Size: 362 B |
|
After Width: | Height: | Size: 366 B |
|
After Width: | Height: | Size: 376 B |
@@ -1,23 +1,38 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"image"
|
||||
"time"
|
||||
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
|
||||
)
|
||||
|
||||
// entryProgressRing draws the per-row TOTP countdown ring. For HOTP
|
||||
// entries it returns a same-sized blank box so the rows still line up.
|
||||
// entryProgressRing draws the per-row TOTP countdown ring with the
|
||||
// vendor icon nested in the centre. For HOTP entries the ring is
|
||||
// omitted (no countdown to show) and the icon fills the same slot so
|
||||
// rows still line up.
|
||||
func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions {
|
||||
const ringDp = 22
|
||||
if en.Auth == nil || en.Auth.Name() == "hotp" {
|
||||
return layout.Dimensions{Size: gtx.Constraints.Constrain(
|
||||
layout.Spacer{Width: unit.Dp(ringDp), Height: unit.Dp(ringDp)}.Layout(gtx).Size,
|
||||
)}
|
||||
const ringDp = 24
|
||||
const iconDp = 16
|
||||
|
||||
if en.Auth == nil {
|
||||
return layout.Dimensions{Size: image.Pt(gtx.Dp(ringDp), gtx.Dp(ringDp))}
|
||||
}
|
||||
|
||||
vendor := en.Auth.Name()
|
||||
|
||||
if vendor == "hotp" {
|
||||
// HOTP has no period to visualise — just show the icon scaled
|
||||
// to the full slot so rows still align.
|
||||
return drawCenteredIcon(gtx, vendor, ringDp, ringDp)
|
||||
}
|
||||
|
||||
period := totpPeriod(en.Auth)
|
||||
if period <= 0 {
|
||||
period = authenticator.DefaultPeriod
|
||||
@@ -33,13 +48,60 @@ func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions {
|
||||
}
|
||||
bg := activePalette.RingBg
|
||||
|
||||
return progressRing{
|
||||
ringDims := progressRing{
|
||||
Size: unit.Dp(ringDp),
|
||||
Stroke: unit.Dp(2.5),
|
||||
Progress: progress,
|
||||
Color: fg,
|
||||
BgColor: bg,
|
||||
}.Layout(gtx)
|
||||
|
||||
// Overlay the vendor icon in the centre of the same slot. We do
|
||||
// this by recording the icon op into a fixed-size context, then
|
||||
// offsetting it so its centre matches the ring's centre.
|
||||
iconPx := gtx.Dp(iconDp)
|
||||
iconGtx := gtx
|
||||
iconGtx.Constraints.Min = image.Pt(iconPx, iconPx)
|
||||
iconGtx.Constraints.Max = image.Pt(iconPx, iconPx)
|
||||
macro := op.Record(gtx.Ops)
|
||||
paintIcon(iconGtx, vendor)
|
||||
call := macro.Stop()
|
||||
offset := image.Pt(
|
||||
(ringDims.Size.X-iconPx)/2,
|
||||
(ringDims.Size.Y-iconPx)/2,
|
||||
)
|
||||
st := op.Offset(offset).Push(gtx.Ops)
|
||||
call.Add(gtx.Ops)
|
||||
st.Pop()
|
||||
return ringDims
|
||||
}
|
||||
|
||||
// drawCenteredIcon paints a vendor icon scaled to fit a w×h slot. Used
|
||||
// for HOTP entries that have no ring around them.
|
||||
func drawCenteredIcon(gtx layout.Context, vendor string, wDp, hDp unit.Dp) layout.Dimensions {
|
||||
wPx, hPx := gtx.Dp(wDp), gtx.Dp(hDp)
|
||||
gtx.Constraints.Min = image.Pt(wPx, hPx)
|
||||
gtx.Constraints.Max = image.Pt(wPx, hPx)
|
||||
return paintIcon(gtx, vendor)
|
||||
}
|
||||
|
||||
// paintIcon draws the vendor icon scaled to fit the current
|
||||
// constraints. Returns its layout dimensions so callers can compose it
|
||||
// inside a Flex. Returns a zero-sized layout if the icon is missing.
|
||||
func paintIcon(gtx layout.Context, vendor string) layout.Dimensions {
|
||||
img := vendorIconFor(vendor)
|
||||
if img == nil {
|
||||
return layout.Dimensions{Size: gtx.Constraints.Min}
|
||||
}
|
||||
w := widget.Image{
|
||||
Src: paint.NewImageOp(img),
|
||||
Fit: widget.Contain,
|
||||
Position: layout.Center,
|
||||
}
|
||||
// Match one source pixel to one device pixel scaled by Fit. We
|
||||
// don't want device-DPI scaling on top of widget.Fit's own
|
||||
// scaling, so leave Scale at its 1.0 default.
|
||||
return w.Layout(gtx)
|
||||
}
|
||||
|
||||
// totpPeriod extracts the configured period from any authenticator whose
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"image"
|
||||
_ "image/png"
|
||||
|
||||
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
|
||||
)
|
||||
|
||||
//go:embed assets/vendor_icons/*.png assets/app_icon.png
|
||||
var assetsFS embed.FS
|
||||
|
||||
// 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.
|
||||
var vendorIcons = map[string]image.Image{}
|
||||
|
||||
func loadVendorIcons() {
|
||||
const fn = "internal.ui.loadVendorIcons"
|
||||
files := []string{"google", "microsoft", "okta", "hotp", "battlenet", "steam", "qr"}
|
||||
for _, name := range files {
|
||||
data, err := assetsFS.ReadFile("assets/vendor_icons/" + name + ".png")
|
||||
if err != nil {
|
||||
global.Log.WithField("func", fn).WithError(err).
|
||||
WithField("vendor", name).Warn("vendor icon missing from embed")
|
||||
continue
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
global.Log.WithField("func", fn).WithError(err).
|
||||
WithField("vendor", name).Warn("vendor icon decode failed")
|
||||
continue
|
||||
}
|
||||
vendorIcons[name] = img
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
if img, ok := vendorIcons[vendor]; 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.
|
||||
func AppIcon() image.Image {
|
||||
data, err := assetsFS.ReadFile("assets/app_icon.png")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return img
|
||||
}
|
||||