From fedc19845296e30dc4df40087ce4d1e856aa16e7 Mon Sep 17 00:00:00 2001 From: Daniel Wu Date: Fri, 12 Jun 2026 03:47:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20vendor=20=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=20+=20=E5=BA=94=E7=94=A8=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 嵌入步骤 --- internal/ui/assets/README.md | 39 ++++ internal/ui/assets/app_icon.png | Bin 0 -> 1285 bytes internal/ui/assets/vendor_icons/battlenet.png | Bin 0 -> 370 bytes internal/ui/assets/vendor_icons/google.png | Bin 0 -> 382 bytes internal/ui/assets/vendor_icons/hotp.png | Bin 0 -> 348 bytes internal/ui/assets/vendor_icons/microsoft.png | Bin 0 -> 359 bytes internal/ui/assets/vendor_icons/okta.png | Bin 0 -> 362 bytes internal/ui/assets/vendor_icons/qr.png | Bin 0 -> 366 bytes internal/ui/assets/vendor_icons/steam.png | Bin 0 -> 376 bytes internal/ui/entry_ring.go | 78 +++++++- internal/ui/vendor_icons.go | 68 +++++++ tools/gen_icons/main.go | 189 ++++++++++++++++++ 12 files changed, 366 insertions(+), 8 deletions(-) create mode 100644 internal/ui/assets/README.md create mode 100644 internal/ui/assets/app_icon.png create mode 100644 internal/ui/assets/vendor_icons/battlenet.png create mode 100644 internal/ui/assets/vendor_icons/google.png create mode 100644 internal/ui/assets/vendor_icons/hotp.png create mode 100644 internal/ui/assets/vendor_icons/microsoft.png create mode 100644 internal/ui/assets/vendor_icons/okta.png create mode 100644 internal/ui/assets/vendor_icons/qr.png create mode 100644 internal/ui/assets/vendor_icons/steam.png create mode 100644 internal/ui/vendor_icons.go create mode 100644 tools/gen_icons/main.go diff --git a/internal/ui/assets/README.md b/internal/ui/assets/README.md new file mode 100644 index 0000000..7535148 --- /dev/null +++ b/internal/ui/assets/README.md @@ -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 +``` diff --git a/internal/ui/assets/app_icon.png b/internal/ui/assets/app_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..97fe512015b19476839fae85ebcf3b2c5f39bdf7 GIT binary patch literal 1285 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|GzJEiGoCJvAr*7p+>Xz?QYPbm zk!>TxuHaphHeKG-9HP+ZWOTCP&-?V>^FGIz*8RNm)5^#0e`!(tK0EvVI<{T=>Z_}y z{_=Rs{Ce0sTic>Yh3{(uoBzRyO$L7)d**GrXejaNaIf4wDZ%F0`UMXU*H*VBOqWmj z@nMIp(}&F0@(F(o&dc4txyGQ4|MksRQa7G38Pr8w51SpeRjZBvcJybdh_^tI8^>ow zN2@X%#t*2_WS+)m;c}1|DW?yC8m|gVEkkHpz{Z}{k3OI4F-$@gEsiz-*0Q%TgQ=5Z@=w5 z4tH#r&-fu#A>-%PN^yo3PB8k**KnWN;qd9T@bp)~+mKzow-BOYFK5Gbh8@eIZgrmyQa<1 z;>!nAdhX%GwRR;yr82h{OV|Do1xn9&mQ#G~J%bBFU+O!x^#4KuEDt260$w2kfrhxuv%F@29UnE4%;ePs#nLxcg*y zV=VKAb!*MG{tSp^Hn@LHe%b8*h7+;uvYN;rw^ZF zcr>K{cG=i4=yx)*E_VNaDN*i;nBxPkK9e8U`F*YWI literal 0 HcmV?d00001 diff --git a/internal/ui/assets/vendor_icons/battlenet.png b/internal/ui/assets/vendor_icons/battlenet.png new file mode 100644 index 0000000000000000000000000000000000000000..a3fae5022e2bfbd7215d37ce755aa8c0857701b4 GIT binary patch literal 370 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEV6^mfaSW-L^JX?<*C7J|*V`N1 zZ`i)560luyZ(&fP!In-%uFBG9_5w@Se)?Z5#Pag|m6Pu)BdhKkPr98suXO{yh}Da=f4(fb?ALJoliSMZtk?&=xyM7SAI{v9UC7^Y#Mxm<{{0Q- zW-(YX_*O6$ANe|8xQMX1CPEJE3nqfpeXnnxU-b9B z{O{d8ejihQutePadQ(nSb1}z$mP6bU``9IZ-k4m?pr)X|_tXNFmUyOztO@&>54~ed zxheE;dIQS^#ta-5T&V8vRloKQf2a;$SC#tGr0$y!41oI z&p$cWl(B?C>N^u(|Lgrn{<3&5$Tqk!y_gm*oOuhDrmUgECOvte5G{ z^#@-+e!Km@e4Jf}g!*aLC7((kUfUR&vytTl=M5)@&T7UJYBMy=ou^kjX!)-D@4(H` zJO9X4op`2~eb*QJdtGQ2L^D&0a{==OgtZ30`+8M_%6-A^IHZ4X>t=^{YdBBTSIlbK zl>KJz2CusxH?in=XOw2YV-!>UV)gRr%bWUlVo$vJQpd``@c%#Kfmp_L?=_cz;mY9Y L>gTe~DWM4fPa&mp literal 0 HcmV?d00001 diff --git a/internal/ui/assets/vendor_icons/hotp.png b/internal/ui/assets/vendor_icons/hotp.png new file mode 100644 index 0000000000000000000000000000000000000000..37a8956c712df916fa52176d13bc918f6d40c56b GIT binary patch literal 348 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEU{v;WaSW-L^JcaqUz34=>%xy) z3gmXIbDN|V5jDH;;A-Rlxdu(2{wF6ae3@*tN$2$CUo)+5ey`mobKkb&%OTq;r&-qj z_f~j%yR6##a_yflOE0@N9RD;kYWo%I1JbF-uiSb#v-tWeV+Eo129I$2$=2CSFBnW7 zvYruq|KIqbx&w$ObP0l+XkKO{kHz literal 0 HcmV?d00001 diff --git a/internal/ui/assets/vendor_icons/microsoft.png b/internal/ui/assets/vendor_icons/microsoft.png new file mode 100644 index 0000000000000000000000000000000000000000..9fc7105b9314976fc1eaa336c5f2e375d66d8c2f GIT binary patch literal 359 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEVAS(;aSW-L^JaEnUz34|Yo;-` z#5MKPm%qN`GfznrIb{*aqFkpotNVpj(*afnYVG<0h!r-8%qwEcwYY__n>1sgMWB^>dQ5p z3z#qL5EqEK_n-5fx&zY-1`{C%_5~9etQdM(E-+@OH1IhnGnOz&fz-+9CG7cA|K(Po zg75?}wbSLhSQQu_ew(rW&lhvq$vfsZe3SR6;rw-K{f_wzAmuN*1+kgp!64h<2Da$J zuby7bHoFWk$Ke0>9sD|TO&Q-S`v*@b>0K(EcC+NpeWq!uDZ8G2XXqB+Np?EKHaV?G1t0_F=I46+SwOfMKrgdErxOk}WP=w-RUn4tnxclqD#2hubC z?~9jfzb`GAymWWN(wZoX?=kv!@)=<|kL_)EH~rw#qUXP=4={-P74ByR=~QMcVUU6; zK^Wd}tvbiYu-8!-WK+Y}^~XOSxE3k=;m`TYA~L&&|NeTH zkfEQPc)=8X?(5TRU4O(nKs5a=s8m~UfJMgrub_&;cUHGud=ih2aa770v@i(GWyNVO z=LWmv$DNCJtAQ=LEPeh~r9$~NmM7v9mp!;;x;3m~>6?n%tTx^&N>|G<+jwP^UOIgF ln!ZeIM`_i6CI*K8|C>L>XzV*P-2fPl44$rjF6*2UngEuvkW~Nx literal 0 HcmV?d00001 diff --git a/internal/ui/assets/vendor_icons/steam.png b/internal/ui/assets/vendor_icons/steam.png new file mode 100644 index 0000000000000000000000000000000000000000..b136e8ff4d997814472fa494cbeca9af608c3fcf GIT binary patch literal 376 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEV6^viaSW-L^JezJ-a`fguEO1V z3z#w(e%U)P2QXe?^iuIYxN&38g!z*H%WwH^TK!*svL#EZvB;*o^7EJ02Twl!)@WMH z{JlHA9gW&`HE@Tr^JUhHb$8ga!(Mx(83_}WM+c5^@7g!MR(sbOnSw1 z=;QK>d>M)C6EfJbndb(w9AstT()Twal3uL?TXf*V(cNWYALg1e{%fDNEaB$WrG62r zZ+7fr)$#5q?S9A5Efh5O%cqi;_BpX0FTd<#Vqp0H|IvxqPhGVa<^V&K!PC{xWt~$( F698PapRfP` literal 0 HcmV?d00001 diff --git a/internal/ui/entry_ring.go b/internal/ui/entry_ring.go index 358a768..e403993 100644 --- a/internal/ui/entry_ring.go +++ b/internal/ui/entry_ring.go @@ -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 diff --git a/internal/ui/vendor_icons.go b/internal/ui/vendor_icons.go new file mode 100644 index 0000000..ff974ec --- /dev/null +++ b/internal/ui/vendor_icons.go @@ -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 +} diff --git a/tools/gen_icons/main.go b/tools/gen_icons/main.go new file mode 100644 index 0000000..e23c30c --- /dev/null +++ b/tools/gen_icons/main.go @@ -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/.png. +// +// Run from the repository root: +// +// go run ./tools/gen_icons +// +// Output: +// - internal/ui/assets/vendor_icons/.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) +}