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
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"image"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
|
"gioui.org/op/paint"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget"
|
||||||
|
|
||||||
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
|
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
|
||||||
)
|
)
|
||||||
|
|
||||||
// entryProgressRing draws the per-row TOTP countdown ring. For HOTP
|
// entryProgressRing draws the per-row TOTP countdown ring with the
|
||||||
// entries it returns a same-sized blank box so the rows still line up.
|
// 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 {
|
func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions {
|
||||||
const ringDp = 22
|
const ringDp = 24
|
||||||
if en.Auth == nil || en.Auth.Name() == "hotp" {
|
const iconDp = 16
|
||||||
return layout.Dimensions{Size: gtx.Constraints.Constrain(
|
|
||||||
layout.Spacer{Width: unit.Dp(ringDp), Height: unit.Dp(ringDp)}.Layout(gtx).Size,
|
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)
|
period := totpPeriod(en.Auth)
|
||||||
if period <= 0 {
|
if period <= 0 {
|
||||||
period = authenticator.DefaultPeriod
|
period = authenticator.DefaultPeriod
|
||||||
@@ -33,13 +48,60 @@ func entryProgressRing(gtx layout.Context, en *entry) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
bg := activePalette.RingBg
|
bg := activePalette.RingBg
|
||||||
|
|
||||||
return progressRing{
|
ringDims := progressRing{
|
||||||
Size: unit.Dp(ringDp),
|
Size: unit.Dp(ringDp),
|
||||||
Stroke: unit.Dp(2.5),
|
Stroke: unit.Dp(2.5),
|
||||||
Progress: progress,
|
Progress: progress,
|
||||||
Color: fg,
|
Color: fg,
|
||||||
BgColor: bg,
|
BgColor: bg,
|
||||||
}.Layout(gtx)
|
}.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
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Code generator for placeholder vendor icons + the app window icon.
|
||||||
|
//
|
||||||
|
// We embed PNG sources rather than drawing the icons in Gio at every
|
||||||
|
// frame so that:
|
||||||
|
// - the work happens once at build/dev time, not per redraw;
|
||||||
|
// - users can swap any single PNG for a real vendor logo without
|
||||||
|
// changing code, just dropping it under internal/ui/assets/
|
||||||
|
// vendor_icons/<name>.png.
|
||||||
|
//
|
||||||
|
// Run from the repository root:
|
||||||
|
//
|
||||||
|
// go run ./tools/gen_icons
|
||||||
|
//
|
||||||
|
// Output:
|
||||||
|
// - internal/ui/assets/vendor_icons/<vendor>.png (7 files)
|
||||||
|
// - internal/ui/assets/app_icon.png (256x256)
|
||||||
|
//
|
||||||
|
// The icons are intentionally generic (coloured rounded squares with a
|
||||||
|
// single white initial) so they carry no third-party trademarks. Replace
|
||||||
|
// them with real vendor logos at your discretion and at your own legal
|
||||||
|
// risk.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"image/png"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/basicfont"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type spec struct {
|
||||||
|
name string // file name without extension; also lookup key
|
||||||
|
letter string // single character to centre on the tile
|
||||||
|
rgb [3]uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
var vendorSpecs = []spec{
|
||||||
|
{"google", "G", [3]uint8{0x42, 0x85, 0xF4}},
|
||||||
|
{"microsoft", "M", [3]uint8{0x00, 0xA4, 0xEF}},
|
||||||
|
{"okta", "O", [3]uint8{0x00, 0x7D, 0xC1}},
|
||||||
|
{"hotp", "#", [3]uint8{0x6A, 0x6A, 0x6A}},
|
||||||
|
{"battlenet", "B", [3]uint8{0x14, 0x8E, 0xFF}},
|
||||||
|
{"steam", "S", [3]uint8{0x1B, 0x2A, 0x38}},
|
||||||
|
// "qr" is used when the entry was added via QR scan and the source
|
||||||
|
// vendor is unknown to us — a neutral grid-like tile keeps the row
|
||||||
|
// from looking broken.
|
||||||
|
{"qr", "Q", [3]uint8{0x3A, 0x3A, 0x3A}},
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
tileSize = 64
|
||||||
|
cornerRad = 12
|
||||||
|
appIconSz = 256
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root := filepath.Join("internal", "ui", "assets")
|
||||||
|
vendorDir := filepath.Join(root, "vendor_icons")
|
||||||
|
if err := os.MkdirAll(vendorDir, 0o755); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, s := range vendorSpecs {
|
||||||
|
path := filepath.Join(vendorDir, s.name+".png")
|
||||||
|
if err := writePNG(path, drawTile(tileSize, cornerRad, s.rgb, s.letter)); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("wrote", path)
|
||||||
|
}
|
||||||
|
appPath := filepath.Join(root, "app_icon.png")
|
||||||
|
if err := writePNG(appPath, drawAppIcon()); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("wrote", appPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawTile paints a rounded square of size×size pixels, filled with the
|
||||||
|
// given RGB and stamped with a single white character in the middle.
|
||||||
|
// We approximate rounded corners by simply skipping pixels outside the
|
||||||
|
// rounded-rect mask; that is cheap and good enough for 64×64 art.
|
||||||
|
func drawTile(size, radius int, rgb [3]uint8, letter string) *image.NRGBA {
|
||||||
|
img := image.NewNRGBA(image.Rect(0, 0, size, size))
|
||||||
|
fill := color.NRGBA{R: rgb[0], G: rgb[1], B: rgb[2], A: 0xff}
|
||||||
|
for y := 0; y < size; y++ {
|
||||||
|
for x := 0; x < size; x++ {
|
||||||
|
if insideRoundRect(x, y, size, size, radius) {
|
||||||
|
img.Set(x, y, fill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stampLetter(img, letter)
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawAppIcon scales the "WinAuth" mark up to 256×256 so we have a
|
||||||
|
// reusable file users can convert to .ico for window-class registration.
|
||||||
|
func drawAppIcon() *image.NRGBA {
|
||||||
|
rgb := [3]uint8{0x10, 0x70, 0xff}
|
||||||
|
img := image.NewNRGBA(image.Rect(0, 0, appIconSz, appIconSz))
|
||||||
|
fill := color.NRGBA{R: rgb[0], G: rgb[1], B: rgb[2], A: 0xff}
|
||||||
|
for y := 0; y < appIconSz; y++ {
|
||||||
|
for x := 0; x < appIconSz; x++ {
|
||||||
|
if insideRoundRect(x, y, appIconSz, appIconSz, 48) {
|
||||||
|
img.Set(x, y, fill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Centred "W" approximation using the basicfont — small but legible.
|
||||||
|
drawCenteredText(img, "W")
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
func insideRoundRect(x, y, w, h, r int) bool {
|
||||||
|
if x < r && y < r {
|
||||||
|
dx, dy := r-x, r-y
|
||||||
|
return dx*dx+dy*dy <= r*r
|
||||||
|
}
|
||||||
|
if x >= w-r && y < r {
|
||||||
|
dx, dy := x-(w-r-1), r-y
|
||||||
|
return dx*dx+dy*dy <= r*r
|
||||||
|
}
|
||||||
|
if x < r && y >= h-r {
|
||||||
|
dx, dy := r-x, y-(h-r-1)
|
||||||
|
return dx*dx+dy*dy <= r*r
|
||||||
|
}
|
||||||
|
if x >= w-r && y >= h-r {
|
||||||
|
dx, dy := x-(w-r-1), y-(h-r-1)
|
||||||
|
return dx*dx+dy*dy <= r*r
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// stampLetter writes a single character centred on the tile using
|
||||||
|
// basicfont.Face7x13. basicfont is part of golang.org/x/image and ships
|
||||||
|
// with the project already, so this adds no new dependency.
|
||||||
|
func stampLetter(img draw.Image, letter string) {
|
||||||
|
face := basicfont.Face7x13
|
||||||
|
advance := font.MeasureString(face, letter).Ceil()
|
||||||
|
bounds := img.Bounds()
|
||||||
|
x := bounds.Min.X + (bounds.Dx()-advance)/2
|
||||||
|
y := bounds.Min.Y + bounds.Dy()/2 + face.Ascent/2
|
||||||
|
d := &font.Drawer{
|
||||||
|
Dst: img,
|
||||||
|
Src: image.NewUniform(color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}),
|
||||||
|
Face: face,
|
||||||
|
Dot: fixed.P(x, y),
|
||||||
|
}
|
||||||
|
d.DrawString(letter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawCenteredText is the same as stampLetter but tuned for the big
|
||||||
|
// app-icon canvas: it stretches the glyph by drawing it several times
|
||||||
|
// with small offsets so the result reads at 256×256.
|
||||||
|
func drawCenteredText(img draw.Image, s string) {
|
||||||
|
face := basicfont.Face7x13
|
||||||
|
advance := font.MeasureString(face, s).Ceil()
|
||||||
|
bounds := img.Bounds()
|
||||||
|
x := bounds.Min.X + (bounds.Dx()-advance*6)/2
|
||||||
|
y := bounds.Min.Y + bounds.Dy()/2 + face.Ascent*3
|
||||||
|
d := &font.Drawer{
|
||||||
|
Dst: img,
|
||||||
|
Src: image.NewUniform(color.NRGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}),
|
||||||
|
Face: face,
|
||||||
|
}
|
||||||
|
// Bloat the small glyph by stamping it in a 6×6 grid of pixel
|
||||||
|
// offsets — crude but yields a readable "W" on the app icon.
|
||||||
|
for dy := 0; dy < 6; dy++ {
|
||||||
|
for dx := 0; dx < 6; dx++ {
|
||||||
|
d.Dot = fixed.P(x+dx, y+dy)
|
||||||
|
d.DrawString(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePNG(path string, img image.Image) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return png.Encode(f, img)
|
||||||
|
}
|
||||||