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 0000000..97fe512 Binary files /dev/null and b/internal/ui/assets/app_icon.png differ diff --git a/internal/ui/assets/vendor_icons/battlenet.png b/internal/ui/assets/vendor_icons/battlenet.png new file mode 100644 index 0000000..a3fae50 Binary files /dev/null and b/internal/ui/assets/vendor_icons/battlenet.png differ diff --git a/internal/ui/assets/vendor_icons/google.png b/internal/ui/assets/vendor_icons/google.png new file mode 100644 index 0000000..330ff07 Binary files /dev/null and b/internal/ui/assets/vendor_icons/google.png differ diff --git a/internal/ui/assets/vendor_icons/hotp.png b/internal/ui/assets/vendor_icons/hotp.png new file mode 100644 index 0000000..37a8956 Binary files /dev/null and b/internal/ui/assets/vendor_icons/hotp.png differ diff --git a/internal/ui/assets/vendor_icons/microsoft.png b/internal/ui/assets/vendor_icons/microsoft.png new file mode 100644 index 0000000..9fc7105 Binary files /dev/null and b/internal/ui/assets/vendor_icons/microsoft.png differ diff --git a/internal/ui/assets/vendor_icons/okta.png b/internal/ui/assets/vendor_icons/okta.png new file mode 100644 index 0000000..0e3209e Binary files /dev/null and b/internal/ui/assets/vendor_icons/okta.png differ diff --git a/internal/ui/assets/vendor_icons/qr.png b/internal/ui/assets/vendor_icons/qr.png new file mode 100644 index 0000000..4b081fe Binary files /dev/null and b/internal/ui/assets/vendor_icons/qr.png differ diff --git a/internal/ui/assets/vendor_icons/steam.png b/internal/ui/assets/vendor_icons/steam.png new file mode 100644 index 0000000..b136e8f Binary files /dev/null and b/internal/ui/assets/vendor_icons/steam.png differ 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) +}