Compare commits

...

8 Commits

Author SHA1 Message Date
iceking2nd 133091a32e feat(ui): 首次启动加密引导 + 真窗口图标 (.syso)
首次启动:
- 配置文件不存在时弹欢迎对话框:选「启用密码保护」或「稍后再说」
- 选启用直接进 setPasswordDialog,OK 时 store.SetPassword 触发首次保存
- io/fs.ErrNotExist 检测新分支
- modalCard 抽出 modalShell 内核以便 welcomeDialog 自定义 footer

真窗口图标:
- tools/gen_ico 从 PNG 生成多分辨率 .ico(7 尺寸:16~256)
- 提交 rsrc_windows.syso(rsrc 通过 goproxy.cn 生成),go build 自动链入
- tray_windows.go 改用嵌入资源 ID=1,失败 fallback IDI_APPLICATION
- README 更新完整的图标生成流程
2026-06-12 10:07:13 +08:00
iceking2nd 727be3557a feat(cli): list/get 子命令 — 脚本集成 TOTP 获取
- winauth list: tabwriter 列出所有条目(名称/vendor/icon/hotkey)
- winauth get NAME: 输出当前 OTP,-n 不换行,--advance 允许 HOTP
- 加密配置自动 tty 隐藏密码提示(golang.org/x/term)
- --config 改为 PersistentFlag,子命令可继承
- HOTP get 默认拒绝,需 --advance 显式确认并保存配置
- CLI 不运行 GUI、不持有单实例锁
2026-06-12 09:50:07 +08:00
iceking2nd 969600b6ee feat(ui): 接入 vendor 真实图标库 + 图标选择器
- assets/icons/ 共 80+ 张真实品牌图标嵌入 UI 包
- entry 新增 IconKey 字段,持久化到 config.Entry.IconName
- iconPickerDialog 网格布局图标选择器
- Add 完成后自动弹出图标选择器,Skip 则用 vendor 占位图
- ⋯ 菜单新增 "Change icon..." 项
- vendorIconFor 解析顺序:catalog → vendor 占位 → qr 兜底
2026-06-12 09:41:30 +08:00
iceking2nd 352b1d24b8 feat(ui): 条目管理增强 — 重命名/删除/重排序
每行右侧增加 ⋯ 按钮,弹出菜单支持重命名、删除、上移、下移。
- renameEntry / deleteEntry / moveEntry 三个 appState 方法
- rowActionMenu 弹出菜单 + renameDialog + confirmDeleteDialog
- 删除自动注销快捷键,所有操作触发配置保存
- 保留顶栏 Add/Remove 全局按钮不变
2026-06-12 09:19:07 +08:00
iceking2nd c0dd3b9028 feat(ui): 系统托盘 + 关闭最小化到托盘
- 原生 win32 实现的 TrayManager:Shell_NotifyIcon + 私有 message-only
  窗口承载 popup 菜单,专用 OS 线程跑 GetMessage 泵,避免引入 cgo 依赖。
- WindowSubclass 通过 SetWindowLongPtrW 子类化 Gio 主窗口,拦截
  WM_CLOSE:minimize_to_tray=true 时吞掉消息只隐藏窗口;exit 路径下
  放行让 Gio 正常关闭事件循环。
- Preferences 新增"最小化到托盘"复选框,切换后立即重装/卸载钩子,
  不需要重启程序。
- 托盘菜单提供 Show / Hide / Exit;左键单击图标等同 Show;右键弹菜单。
- 非 Windows 平台所有相关 API 退化为 ErrUnsupported / no-op stub。
2026-06-12 04:13:35 +08:00
iceking2nd 0918fd446a feat(ui): 安全增强 — 修改密码 / 自动锁定 / HOTP 提示 / 配置备份
- 修改密码对话框引入旧密码校验:通过 store.VerifyPassword 走 constant-time
  比较,对话框自身不持有 live passphrase 副本。
- 配置加密且 auto_lock_minutes>0 时启用自动锁定;明文配置不受影响。
  锁定时刻意保留 store.passphrase,避免解锁瞬间出现保存竞态——进程内存
  无论如何都是威胁边界。
- HOTP 点击后 toast 仅显示推进后的计数器值,绝不携带验证码字符串。
- SaveYAML 每次写入前将旧文件轮转为 .bak(0o600),防止加密/序列化失败
  导致用户无可恢复副本。
- Preferences 对话框新增"自动锁定(分钟)"输入项。
2026-06-12 03:57:01 +08:00
iceking2nd fedc198452 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 嵌入步骤
2026-06-12 03:47:00 +08:00
iceking2nd b483360778 feat(ui): 主题切换 + 语言持久化 + About 对话框
- 新增 light/dark 主题调色板与 Windows 注册表系统主题探测
- 偏好设置对话框:主题三选一 + 语言下拉(en/zh-CN/de),即时生效
- 持久化 Language/Theme/AutoLockMinutes/MinimizeToTray 至 YAML 顶层
- 启动时按 config 中保存的语言初始化 i18n
- About 对话框展示 version/runtime/项目地址/许可证
- 全部硬编码颜色迁移到 themePalette,为深色模式做准备
2026-06-12 03:38:13 +08:00
139 changed files with 3501 additions and 83 deletions
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
func newGetCmd() *cobra.Command {
var noNewline, advance bool
cmd := &cobra.Command{
Use: "get NAME",
Short: "Print the current OTP for an entry",
Long: `Print the current one-time password for the entry named NAME.
The name must match exactly (case-sensitive).
For HOTP (counter-based) entries, this command refuses by default
because generating a code advances the counter. Use --advance to
confirm that you want to consume a counter value; the config file
will be updated automatically.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runGet(cmd, args[0], noNewline, advance)
},
}
cmd.Flags().BoolVarP(&noNewline, "no-newline", "n", false, "Do not append a newline after the code")
cmd.Flags().BoolVar(&advance, "advance", false, "Advance HOTP counter (required for counter-based entries)")
return cmd
}
func runGet(cmd *cobra.Command, name string, noNewline, advance bool) error {
path := resolveConfigPath(cmd)
lang := preferredLanguage(path)
_ = i18n.Init(lang)
cfg, passphrase, err := cliLoadConfig(path)
if err != nil {
if errors.Is(err, config.ErrPasswordWrong) {
return fmt.Errorf("wrong password")
}
return fmt.Errorf("load config: %w", err)
}
// Find entry by exact name.
var target *config.Entry
var matches []string
for i := range cfg.Entries {
if cfg.Entries[i].Name == name {
target = &cfg.Entries[i]
break
}
matches = append(matches, cfg.Entries[i].Name)
}
if target == nil {
return fmt.Errorf("entry %q not found", name)
}
a, err := buildAuth(*target)
if err != nil {
return fmt.Errorf("build authenticator: %w", err)
}
// HOTP safety gate.
if a.Name() == "hotp" && !advance {
return fmt.Errorf("entry %q is counter-based (HOTP); re-run with --advance to consume a counter value", name)
}
code, err := a.CurrentCode()
if err != nil {
return fmt.Errorf("compute OTP: %w", err)
}
if noNewline {
fmt.Print(code)
} else {
fmt.Println(code)
}
// Persist the updated counter for HOTP entries.
if a.Name() == "hotp" {
target.SecretRaw = a.SecretData()
if err := config.SaveYAML(cfg, path, passphrase); err != nil {
return fmt.Errorf("save config after HOTP advance: %w", err)
}
}
return nil
}
// buildAuth mirrors the switch in internal/ui/convert.go. Duplicated here
// so the CLI binary does not depend on the UI package.
func buildAuth(e config.Entry) (authenticator.Authenticator, error) {
var a authenticator.Authenticator
switch e.Vendor {
case "google", "":
a = authenticator.NewGoogleAuthenticator()
case "microsoft":
a = authenticator.NewMicrosoftAuthenticator()
case "okta":
a = authenticator.NewOktaVerifyAuthenticator()
case "hotp":
a = authenticator.NewHOTPAuthenticator()
case "battlenet":
a = authenticator.NewBattleNetAuthenticator()
case "steam":
a = authenticator.NewSteamAuthenticator()
default:
return nil, fmt.Errorf("unknown vendor %q", e.Vendor)
}
if err := a.SetSecretData(e.SecretRaw); err != nil {
return nil, fmt.Errorf("decode entry %q: %w", e.Name, err)
}
return a, nil
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"errors"
"fmt"
"os"
"text/tabwriter"
"github.com/spf13/cobra"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
)
func newListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all authenticator entries",
Long: "Prints a table of all entries in the config file. For encrypted configs, a password prompt will appear.",
Args: cobra.NoArgs,
RunE: runList,
}
}
func runList(cmd *cobra.Command, args []string) error {
const fn = "cmd.winauth.runList"
path := resolveConfigPath(cmd)
lang := preferredLanguage(path)
_ = i18n.Init(lang) // best-effort; list output is English anyway
cfg, _, err := cliLoadConfig(path)
if err != nil {
if errors.Is(err, config.ErrPasswordWrong) {
return fmt.Errorf("wrong password")
}
return fmt.Errorf("load config: %w", err)
}
if len(cfg.Entries) == 0 {
fmt.Fprintln(os.Stderr, "No entries found.")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tVENDOR\tICON\tHOTKEY")
for _, e := range cfg.Entries {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", e.Name, e.Vendor, e.IconName, e.Hotkey)
}
return tw.Flush()
}
+50
View File
@@ -0,0 +1,50 @@
package main
import (
"errors"
"fmt"
"os"
"github.com/spf13/cobra"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
)
// resolveConfigPath returns the config file path from the --config
// flag or the default location.
func resolveConfigPath(cmd *cobra.Command) string {
if p, _ := cmd.Root().PersistentFlags().GetString("config"); p != "" {
return p
}
return config.DefaultPath()
}
// cliLoadConfig loads the config at path, prompting for a password if
// the file is encrypted. Returns the config and the passphrase (empty
// for plaintext configs). Up to 3 password attempts are allowed.
func cliLoadConfig(path string) (*config.Config, []byte, error) {
cfg, err := config.LoadYAML(path, nil)
if err == nil {
return cfg, nil, nil
}
if !errors.Is(err, config.ErrPasswordRequired) {
return nil, nil, err
}
for attempt := 0; attempt < 3; attempt++ {
pw, err := readPassword("Password: ")
if err != nil {
return nil, nil, fmt.Errorf("read password: %w", err)
}
cfg, err = config.LoadYAML(path, pw)
if err == nil {
return cfg, pw, nil
}
if errors.Is(err, config.ErrPasswordWrong) {
fmt.Fprintln(os.Stderr, "Wrong password.")
continue
}
return nil, nil, err
}
return nil, nil, fmt.Errorf("too many failed password attempts")
}
+30 -3
View File
@@ -6,10 +6,12 @@ import (
"github.com/spf13/cobra"
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
"git.wxccs.org/iceking2nd/winauth-go/internal/global"
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
"git.wxccs.org/iceking2nd/winauth-go/internal/logging"
"git.wxccs.org/iceking2nd/winauth-go/internal/ui"
"git.wxccs.org/iceking2nd/winauth-go/internal/version"
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
)
@@ -53,7 +55,8 @@ func main() {
}
const fn = "cmd.winauth.main"
global.Log.WithField("func", fn).Info("winauth-go starting")
global.Log.WithField("func", fn).
WithField("version", version.String()).Info("winauth-go starting")
release, alreadyRunning, err := win32.AcquireInstanceLock(singleInstanceMutex)
if err != nil {
@@ -70,7 +73,12 @@ func main() {
defer release()
}
if err := i18n.Init(""); err != nil {
// Peek the config (without password) to recover the user's
// last language choice before initialising i18n. Encrypted
// configs still expose Language at the YAML top level so we
// can localise the password prompt itself.
lang := preferredLanguage(configPath)
if err := i18n.Init(lang); err != nil {
global.Log.WithField("func", fn).WithError(err).Warn("i18n init failed; falling back to keys")
}
@@ -83,11 +91,30 @@ func main() {
"Log level: panic|fatal|error|warn|info|debug|trace (or 0..6)")
root.Flags().StringVar(&logFile, "log-file", "",
"If set, also write logs to this file in addition to the console")
root.Flags().StringVar(&configPath, "config", "",
root.PersistentFlags().StringVar(&configPath, "config", "",
"Path to the YAML config file (default: %APPDATA%\\winauth-go\\config.yaml or $XDG_CONFIG_HOME/winauth-go/config.yaml)")
root.AddCommand(newListCmd())
root.AddCommand(newGetCmd())
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
// preferredLanguage reads the user's saved language tag out of the YAML
// config without requiring a passphrase. We resolve the default config
// path when configPath is blank and silently return "" on any failure;
// callers fall back to the i18n default in that case. Encrypted configs
// still expose the top-level Language field so this works for them too.
func preferredLanguage(configPath string) string {
if configPath == "" {
configPath = config.DefaultPath()
}
cfg, _ := config.LoadYAML(configPath, nil)
if cfg == nil {
return ""
}
return cfg.Language
}
+31
View File
@@ -0,0 +1,31 @@
package main
import (
"bufio"
"fmt"
"os"
"golang.org/x/term"
)
// readPassword prompts the user for a password on the given file
// descriptor. If the fd is a terminal the input is hidden; otherwise
// (piped stdin) it falls back to a plain read.
func readPassword(prompt string) ([]byte, error) {
fd := int(os.Stdin.Fd())
if term.IsTerminal(fd) {
fmt.Fprint(os.Stderr, prompt)
pw, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
return pw, err
}
// Non-tty (piped): read a line without echo hiding.
scanner := bufio.NewScanner(os.Stdin)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return nil, err
}
return nil, fmt.Errorf("no input on stdin")
}
return []byte(scanner.Text()), nil
}
+5 -4
View File
@@ -1,14 +1,18 @@
module git.wxccs.org/iceking2nd/winauth-go
go 1.23
go 1.25.0
require (
gioui.org v0.7.1
github.com/BurntSushi/toml v1.4.0
github.com/makiuchi-d/gozxing v0.1.1
github.com/nicksnyder/go-i18n/v2 v2.4.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.8.1
golang.org/x/crypto v0.28.0
golang.org/x/image v0.18.0
golang.org/x/sys v0.46.0
golang.org/x/term v0.44.0
golang.org/x/text v0.19.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -18,11 +22,8 @@ require (
gioui.org/shader v1.0.8 // indirect
github.com/go-text/typesetting v0.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/makiuchi-d/gozxing v0.1.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect
golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
)
+4 -2
View File
@@ -44,8 +44,10 @@ golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRY
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+13 -5
View File
@@ -21,9 +21,17 @@ type Entry struct {
// default; if Encrypted is true, EncryptedBlob holds a WAGO1 base64 ciphertext
// produced by internal/crypto.EncryptModern and Entries is empty on disk.
type Config struct {
Version int `yaml:"version" json:"version"`
Language string `yaml:"language,omitempty" json:"language,omitempty"`
Encrypted bool `yaml:"encrypted" json:"encrypted"`
EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"`
Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"`
Version int `yaml:"version" json:"version"`
Language string `yaml:"language,omitempty" json:"language,omitempty"`
// Theme is one of "", "light", "dark", "system" (empty = system).
Theme string `yaml:"theme,omitempty" json:"theme,omitempty"`
// AutoLockMinutes locks the UI (hides codes, requires password) after
// this many minutes of inactivity. 0 disables auto-lock.
AutoLockMinutes int `yaml:"auto_lock_minutes,omitempty" json:"auto_lock_minutes,omitempty"`
// MinimizeToTray sends the window to the system tray instead of
// exiting when the close button is pressed.
MinimizeToTray bool `yaml:"minimize_to_tray,omitempty" json:"minimize_to_tray,omitempty"`
Encrypted bool `yaml:"encrypted" json:"encrypted"`
EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"`
Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"`
}
+38
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"gopkg.in/yaml.v3"
@@ -49,6 +50,14 @@ func SaveYAML(cfg *Config, path string, passphrase []byte) error {
if err := EnsureDir(path); err != nil {
return err
}
// Best-effort .bak rotation: if a previous file exists, copy it to
// path+".bak" before we overwrite, so a botched encrypt / serialize
// does not leave the user with no recoverable copy. Failure to back
// up is logged but does not block the save — the data is more
// important than the safety net.
if err := backupIfExists(path); err != nil {
logger.WithError(err).Warn("backup before save failed")
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
@@ -61,6 +70,35 @@ func SaveYAML(cfg *Config, path string, passphrase []byte) error {
return nil
}
// backupIfExists copies path to path+".bak" with 0o600 permissions when
// path exists, atomically overwriting any previous .bak. Returns nil if
// path does not exist (first save).
func backupIfExists(path string) error {
src, err := os.Open(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
defer src.Close()
bakTmp := path + ".bak.tmp"
dst, err := os.OpenFile(bakTmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := io.Copy(dst, src); err != nil {
dst.Close()
_ = os.Remove(bakTmp)
return err
}
if err := dst.Close(); err != nil {
_ = os.Remove(bakTmp)
return err
}
return os.Rename(bakTmp, path+".bak")
}
// LoadYAML reads the YAML config at path. If the file is encrypted,
// passphrase is required and the EncryptedBlob is decrypted into Entries.
func LoadYAML(path string, passphrase []byte) (*Config, error) {
+156
View File
@@ -367,3 +367,159 @@ other = "QR-Scan fehlgeschlagen: %s"
[msg_clipboard_no_image]
other = "Zwischenablage enthält kein Bild"
# --- Einstellungen ---
[menu_preferences]
other = "Einstellungen..."
[dialog_preferences_title]
other = "Einstellungen"
[label_theme]
other = "Design"
[theme_system]
other = "System"
[theme_light]
other = "Hell"
[theme_dark]
other = "Dunkel"
[label_language]
other = "Sprache"
# --- Über-Dialog ---
[dialog_about_title]
other = "Über WinAuth"
[about_app_name]
other = "WinAuth (Go-Portierung) — TOTP/HOTP-Authentifikator"
[about_version_label]
other = "Version"
[about_runtime_label]
other = "Laufzeit"
[about_project_label]
other = "Projekt"
[about_project_url]
other = "https://git.wxccs.org/iceking2nd/winauth-go"
[about_license_label]
other = "Lizenz"
[about_license_value]
other = "MIT"
[about_credits]
other = "Original-WinAuth von Colin Mackie. Go-Portierung wird von den Projektautoren gepflegt."
# --- Sicherheit: Passwort ändern / automatische Sperre / HOTP-Hinweis ---
[menu_change_password]
other = "Passwort ändern..."
[dialog_change_password_title]
other = "Passwort ändern"
[label_password_current]
other = "Aktuelles Passwort"
[label_password_new]
other = "Neues Passwort"
[msg_password_changed]
other = "Passwort geändert."
[msg_hotp_advanced]
other = "Zähler auf %d erhöht"
[msg_locked]
other = "Wegen Inaktivität gesperrt. Passwort zum Entsperren eingeben."
[label_auto_lock_minutes]
other = "Automatisch sperren nach (Minuten)"
[hint_auto_lock_disabled]
other = "0 deaktiviert. Nur wirksam, wenn die Konfiguration verschlüsselt ist."
# --- System-Tray ---
[tray_show]
other = "WinAuth anzeigen"
[tray_hide]
other = "WinAuth ausblenden"
[tray_exit]
other = "Beenden"
[label_minimize_to_tray]
other = "Beim Schließen in den Tray minimieren"
[hint_minimize_to_tray]
other = "Schließen des Fensters versteckt es im System-Tray statt das Programm zu beenden."
# --- Eintragsaktionen (umbenennen / löschen / verschieben) ---
[btn_more]
other = "⋯"
[btn_delete]
other = "Löschen"
[dialog_row_actions_title]
other = "Eintragsaktionen"
[action_rename]
other = "Umbenennen..."
[action_delete]
other = "Löschen..."
[action_move_up]
other = "Nach oben"
[action_move_down]
other = "Nach unten"
[dialog_rename_title]
other = "Eintrag umbenennen"
[label_entry_name]
other = "Neuer Name"
[msg_empty_name]
other = "Name darf nicht leer sein."
[dialog_confirm_delete_title]
other = "Löschen bestätigen"
[msg_confirm_delete]
other = "Eintrag \"%s\" wirklich löschen? Dies kann nicht rückgängig gemacht werden."
[action_change_icon]
other = "Symbol ändern..."
[dialog_icon_picker_title]
other = "Symbol auswählen"
# --- Erster Start: Verschlüsselungsauswahl ---
[dialog_welcome_title]
other = "Willkommen bei WinAuth"
[msg_welcome_intro]
other = "Wähle, ob die Liste der Authenticatoren verschlüsselt auf der Festplatte gespeichert werden soll. Die Verschlüsselung schützt die Datei mit einem Passwort deiner Wahl. Du kannst dies später jederzeit in den Einstellungen ändern."
[btn_welcome_enable_password]
other = "Passwortschutz aktivieren"
[btn_welcome_skip]
other = "Vorerst überspringen"
+142
View File
@@ -369,3 +369,145 @@ other = "QR scan failed: %s"
[msg_clipboard_no_image]
other = "Clipboard does not contain an image"
# --- Preferences ---
[menu_preferences]
other = "Preferences..."
[dialog_preferences_title]
other = "Preferences"
[label_theme]
other = "Theme"
[theme_system]
other = "System"
[theme_light]
other = "Light"
[theme_dark]
other = "Dark"
[label_language]
other = "Language"
# --- About dialog ---
[dialog_about_title]
other = "About WinAuth"
[about_app_name]
other = "WinAuth (Go port) — TOTP/HOTP authenticator"
[about_version_label]
other = "Version"
[about_runtime_label]
other = "Runtime"
[about_project_label]
other = "Project"
[about_project_url]
other = "https://git.wxccs.org/iceking2nd/winauth-go"
[about_license_label]
other = "License"
[about_license_value]
other = "MIT"
[about_credits]
other = "Original WinAuth by Colin Mackie. Go port maintained by the project authors."
# --- Security: change password / auto-lock / HOTP toast ---
[menu_change_password]
other = "Change password..."
[dialog_change_password_title]
other = "Change password"
[label_password_current]
other = "Current password"
[label_password_new]
other = "New password"
[msg_password_changed]
other = "Password changed."
[msg_hotp_advanced]
other = "Counter advanced to %d"
[msg_locked]
other = "Locked due to inactivity. Enter the password to unlock."
[label_auto_lock_minutes]
other = "Auto-lock after (minutes)"
[hint_auto_lock_disabled]
other = "Set to 0 to disable. Only takes effect when the configuration is encrypted."
# --- System tray ---
[tray_show]
other = "Show WinAuth"
[tray_hide]
other = "Hide WinAuth"
[tray_exit]
other = "Exit"
[label_minimize_to_tray]
other = "Minimize to tray when closing the window"
[hint_minimize_to_tray]
other = "Closing the window hides it to the system tray instead of exiting."
# --- Entry actions (rename / delete / reorder) ---
[btn_more]
other = "⋯"
[btn_delete]
other = "Delete"
[dialog_row_actions_title]
other = "Entry actions"
[action_rename]
other = "Rename..."
[action_delete]
other = "Delete..."
[action_move_up]
other = "Move up"
[action_move_down]
other = "Move down"
[dialog_rename_title]
other = "Rename entry"
[label_entry_name]
other = "New name"
[msg_empty_name]
other = "Name cannot be empty."
[dialog_confirm_delete_title]
other = "Confirm delete"
[msg_confirm_delete]
other = "Delete the entry \"%s\"? This cannot be undone."
[action_change_icon]
other = "Change icon..."
[dialog_icon_picker_title]
other = "Choose icon"
+142
View File
@@ -367,3 +367,145 @@ other = "二维码扫描失败:%s"
[msg_clipboard_no_image]
other = "剪贴板中没有图片"
# --- 偏好设置 ---
[menu_preferences]
other = "偏好设置..."
[dialog_preferences_title]
other = "偏好设置"
[label_theme]
other = "主题"
[theme_system]
other = "跟随系统"
[theme_light]
other = "浅色"
[theme_dark]
other = "深色"
[label_language]
other = "语言"
# --- 关于对话框 ---
[dialog_about_title]
other = "关于 WinAuth"
[about_app_name]
other = "WinAuth (Go 版) — TOTP/HOTP 验证器"
[about_version_label]
other = "版本"
[about_runtime_label]
other = "运行时"
[about_project_label]
other = "项目主页"
[about_project_url]
other = "https://git.wxccs.org/iceking2nd/winauth-go"
[about_license_label]
other = "许可证"
[about_license_value]
other = "MIT"
[about_credits]
other = "原版 WinAuth 由 Colin Mackie 创作。Go 移植由本项目作者维护。"
# --- 安全:修改密码 / 自动锁定 / HOTP 提示 ---
[menu_change_password]
other = "修改密码..."
[dialog_change_password_title]
other = "修改密码"
[label_password_current]
other = "当前密码"
[label_password_new]
other = "新密码"
[msg_password_changed]
other = "密码已更新。"
[msg_hotp_advanced]
other = "计数器推进到 %d"
[msg_locked]
other = "因长时间无操作已锁定,请输入密码解锁。"
[label_auto_lock_minutes]
other = "自动锁定(分钟)"
[hint_auto_lock_disabled]
other = "填 0 关闭。仅当配置已加密时生效。"
# --- 系统托盘 ---
[tray_show]
other = "显示 WinAuth"
[tray_hide]
other = "隐藏 WinAuth"
[tray_exit]
other = "退出"
[label_minimize_to_tray]
other = "关闭窗口时最小化到托盘"
[hint_minimize_to_tray]
other = "勾选后点击窗口关闭按钮将隐藏到系统托盘而非退出程序。"
# --- 条目操作(重命名 / 删除 / 重排序) ---
[btn_more]
other = "⋯"
[btn_delete]
other = "删除"
[dialog_row_actions_title]
other = "条目操作"
[action_rename]
other = "重命名..."
[action_delete]
other = "删除..."
[action_move_up]
other = "上移"
[action_move_down]
other = "下移"
[dialog_rename_title]
other = "重命名条目"
[label_entry_name]
other = "新名称"
[msg_empty_name]
other = "名称不能为空。"
[dialog_confirm_delete_title]
other = "确认删除"
[msg_confirm_delete]
other = "确定删除条目 \"%s\"?此操作不可撤销。"
[action_change_icon]
other = "更换图标..."
[dialog_icon_picker_title]
other = "选择图标"
+361 -15
View File
@@ -5,17 +5,16 @@ import (
"errors"
"fmt"
"image/color"
"io/fs"
"os"
"sync"
"time"
"gioui.org/app"
"gioui.org/font/gofont"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
@@ -58,6 +57,11 @@ type entry struct {
Auth authenticator.Authenticator
Code string
// IconKey selects a specific brand icon from the embedded catalog
// (e.g. "GitHubIcon", "DropboxIcon"). Empty means fall back to the
// vendor-name placeholder ("google", "steam", …).
IconKey string
// Hotkey is the user-configured global shortcut string ("Ctrl+Alt+G")
// or "" if none is set.
Hotkey string
@@ -75,6 +79,8 @@ type entry struct {
hotkeyBtn widget.Clickable
// copyBtn copies the current code to the clipboard.
copyBtn widget.Clickable
// moreBtn opens the per-row ⋯ popup (rename / delete / move).
moreBtn widget.Clickable
}
type appState struct {
@@ -91,10 +97,24 @@ type appState struct {
dialog Dialog
pwDialog *passwordDialog
setPwDialog *setPasswordDialog
changePwDlg *changePasswordDialog
welcomeDlg *welcomeDialog
importDialog *importLegacyDialog
hotkeyDialog *hotkeyDialog
hotkeyTarget *entry
tradesDialog *steamTradesDialog
prefsDialog *preferencesDialog
aboutDialog *aboutDialog
// Per-row action popup state. rowMenu owns the visible popup;
// renameDlg / confirmDelDlg are the follow-up modals it spawns.
// rowTargetIdx is the entry index the popup applies to; it is
// re-resolved on each frame in case the entries slice mutated.
rowMenu *rowActionMenu
renameDlg *renameDialog
confirmDelDlg *confirmDeleteDialog
iconPickerDlg *iconPickerDialog
rowTargetIdx int
store *store
saveErr string // surfaced in the top bar
@@ -102,6 +122,27 @@ type appState struct {
hkMgr *win32.HotkeyManager
toast toast
// themeMode is the user's current theme preference. drawFrame
// rebuilds the material.Theme when this changes.
themeMode themeMode
// theme is the cached material.Theme matching themeMode. nil forces a
// rebuild on the next frame.
theme *material.Theme
// Auto-lock state. Active only when the config is encrypted and the
// user set auto_lock_minutes > 0. lastActivity is bumped on any UI
// interaction; once now-lastActivity exceeds the threshold the lock
// dialog is raised and the entry list hidden until the user
// re-enters the live passphrase.
lastActivity time.Time
locked bool
lockDialog *passwordDialog
// tray owns the system-tray icon and (when minimize_to_tray is on)
// the WM_CLOSE subclass. nil on platforms or installs where it
// could not be set up.
tray *trayRuntime
}
// snapshotEntries returns a freshly serialized slice of config entries.
@@ -111,7 +152,7 @@ func (st *appState) snapshotEntries() []config.Entry {
defer st.mu.Unlock()
out := make([]config.Entry, 0, len(st.entries))
for _, en := range st.entries {
out = append(out, entryFromAuthenticator(en.Name, en.Auth, en.Hotkey))
out = append(out, entryFromAuthenticator(en.Name, en.Auth, en.Hotkey, en.IconKey))
}
return out
}
@@ -119,11 +160,9 @@ func (st *appState) snapshotEntries() []config.Entry {
func loop(w *app.Window, configPath string) error {
const fn = "internal.ui.loop"
th := material.NewTheme()
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
state := &appState{}
state.list.Axis = layout.Vertical
state.lastActivity = time.Now()
state.store = newStore(configPath, state.snapshotEntries, func(err error) {
state.mu.Lock()
@@ -132,10 +171,15 @@ func loop(w *app.Window, configPath string) error {
w.Invalidate()
})
// First-load attempt: empty passphrase. If the file is encrypted we'll
// surface a password dialog on the first frame.
// First-load attempt: empty passphrase. The branches are:
// - file missing → first-run welcome dialog (encrypt / skip)
// - file encrypted → password dialog
// - other load error → log + show error banner
if cfg, err := state.store.Load(nil); err != nil {
switch {
case errors.Is(err, fs.ErrNotExist):
global.Log.WithField("func", fn).Info("no config file; showing first-run welcome")
state.welcomeDlg = newWelcomeDialog()
case errors.Is(err, ErrPasswordRequired):
state.pwDialog = newPasswordDialog(i18n.T("msg_password_required"))
default:
@@ -146,6 +190,12 @@ func loop(w *app.Window, configPath string) error {
state.absorbConfig(cfg)
}
// Seed the theme cache from the stored preference. Empty / unknown
// values resolve to "system" which buildTheme then maps to light or
// dark via the OS-specific systemPrefersDark probe.
_, themePref, _, _ := state.store.Preferences()
state.themeMode = normalizeThemeMode(themePref)
// Spin up the global hotkey manager and register whatever the user
// already had configured. Failures are non-fatal (logged + the row
// just won't fire).
@@ -153,6 +203,17 @@ func loop(w *app.Window, configPath string) error {
state.registerAllHotkeys()
go state.runHotkeyLoop(w)
// Install the system tray icon + close-hook (Windows only). HWND
// discovery races the very first FrameEvent, so kick it from a
// goroutine that polls FindWindow for a few hundred ms.
_, _, _, minToTray := state.store.Preferences()
go func() {
tr := installTrayRuntime(i18n.T("app_title"), minToTray, w.Invalidate)
state.mu.Lock()
state.tray = tr
state.mu.Unlock()
}()
// Tick once per second to refresh TOTP codes.
go func() {
t := time.NewTicker(time.Second)
@@ -167,15 +228,60 @@ func loop(w *app.Window, configPath string) error {
switch e := w.Event().(type) {
case app.DestroyEvent:
global.Log.WithField("func", fn).Info("window closed")
state.tray.Stop()
return e.Err
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
drawFrame(gtx, th, state, w)
if state.theme == nil {
th, pal := buildTheme(state.themeMode)
state.theme = th
activePalette = pal
}
// Paint the window background using the active palette so
// dark mode does not show through as the Gio default grey.
fillBackground(gtx, activePalette.Background)
drawFrame(gtx, state.theme, state, w)
e.Frame(gtx.Ops)
}
}
}
// maybeAutoLock checks whether the inactivity threshold has elapsed and,
// if so, raises the lock dialog. Returns true when the UI should render
// the lock prompt this frame and skip the rest of the layout. The check
// is a no-op unless the configuration is encrypted AND the user set
// auto_lock_minutes > 0 — locking a plaintext config buys nothing
// security-wise (the file is already readable).
//
// Note: we do NOT clear store.passphrase on lock. Re-decrypt on every
// unlock would mean a transient window where we cannot save (e.g. a
// hotkey press just before unlock would race), and it would not improve
// the security model — process memory holding the key is the threat
// either way.
func (st *appState) maybeAutoLock() bool {
if st.locked {
return true
}
if !st.store.Encrypted() {
return false
}
_, _, autoLockMinutes, _ := st.store.Preferences()
if autoLockMinutes <= 0 {
return false
}
if st.lastActivity.IsZero() {
st.lastActivity = time.Now()
return false
}
threshold := time.Duration(autoLockMinutes) * time.Minute
if time.Since(st.lastActivity) < threshold {
return false
}
st.locked = true
st.lockDialog = newPasswordDialog(i18n.T("msg_locked"))
return true
}
// absorbConfig replaces the in-memory entries with the contents of cfg,
// best-effort: bad entries are logged and skipped.
func (st *appState) absorbConfig(cfg *config.Config) {
@@ -192,7 +298,7 @@ func (st *appState) absorbConfig(cfg *config.Config) {
global.Log.WithField("func", fn).WithError(err).Warn("skip entry")
continue
}
st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey})
st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey, IconKey: e.IconName})
}
}
@@ -212,13 +318,88 @@ func (st *appState) mergeImportedConfig(cfg *config.Config) {
global.Log.WithField("func", fn).WithError(err).Warn("skip imported entry")
continue
}
st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey})
st.entries = append(st.entries, &entry{Name: e.Name, Auth: a, Hotkey: e.Hotkey, IconKey: e.IconName})
}
}
// renameEntry sets a new display name for the entry at idx. Empty names
// are rejected by the caller; here we trust the input and only guard
// the index. Pushes a save on success.
func (st *appState) renameEntry(idx int, name string) {
st.mu.Lock()
if idx < 0 || idx >= len(st.entries) {
st.mu.Unlock()
return
}
st.entries[idx].Name = name
st.mu.Unlock()
st.store.Push()
}
// deleteEntry removes the entry at idx, unregistering its hotkey if
// any. Bounds-checked; out-of-range indexes are a silent no-op so a
// stale popup that survived a concurrent mutation cannot panic.
func (st *appState) deleteEntry(idx int) {
const fn = "internal.ui.appState.deleteEntry"
st.mu.Lock()
if idx < 0 || idx >= len(st.entries) {
st.mu.Unlock()
return
}
en := st.entries[idx]
if en.hotkeyID != 0 && st.hkMgr != nil {
if err := st.hkMgr.Unregister(en.hotkeyID); err != nil {
global.Log.WithField("func", fn).WithError(err).
Warn("unregister hotkey during delete failed")
}
en.hotkeyID = 0
}
st.entries = append(st.entries[:idx], st.entries[idx+1:]...)
st.mu.Unlock()
st.store.Push()
}
// moveEntry swaps the entry at idx with its neighbour delta steps away
// (typically ±1). Bounds-checked. Pushes a save on a successful swap.
func (st *appState) moveEntry(idx, delta int) {
st.mu.Lock()
j := idx + delta
if idx < 0 || idx >= len(st.entries) || j < 0 || j >= len(st.entries) {
st.mu.Unlock()
return
}
st.entries[idx], st.entries[j] = st.entries[j], st.entries[idx]
st.mu.Unlock()
st.store.Push()
}
func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Window) layout.Dimensions {
// Auto-lock check runs before any input is dispatched so a user who
// returns mid-frame still has to type the passphrase before they can
// interact. Active only when the config is encrypted and the user
// configured a positive timeout.
if st.maybeAutoLock() && st.lockDialog != nil {
return st.lockDialog.Layout(gtx, th, func(pw []byte, ok bool) {
if !ok {
// Cancel is meaningless while locked — just re-prompt.
st.lockDialog.SetError(i18n.T("msg_locked"))
w.Invalidate()
return
}
if st.store.VerifyPassword(pw) {
st.locked = false
st.lockDialog = nil
st.lastActivity = time.Now()
} else {
st.lockDialog.SetError(i18n.T("msg_password_wrong"))
}
w.Invalidate()
})
}
if st.addBtn.Clicked(gtx) {
st.vendorMenu = newVendorMenu()
st.lastActivity = time.Now()
}
if st.removeBtn.Clicked(gtx) {
st.mu.Lock()
@@ -227,9 +408,11 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
}
st.mu.Unlock()
st.store.Push()
st.lastActivity = time.Now()
}
if st.settingsBtn.Clicked(gtx) {
st.settingsMenu = newSettingsMenu()
st.lastActivity = time.Now()
}
// Refresh TOTP codes on every frame; HOTP entries advance on user click only.
@@ -237,22 +420,36 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
var tradesTarget *entry
var hotkeyTarget *entry
var copyTarget *entry
for _, en := range st.entries {
moreTargetIdx := -1
for i, en := range st.entries {
if en.Auth.Name() == "steam" {
if en.tradesBtn.Clicked(gtx) {
tradesTarget = en
st.lastActivity = time.Now()
}
}
if en.hotkeyBtn.Clicked(gtx) {
hotkeyTarget = en
st.lastActivity = time.Now()
}
if en.copyBtn.Clicked(gtx) {
copyTarget = en
st.lastActivity = time.Now()
}
if en.moreBtn.Clicked(gtx) {
moreTargetIdx = i
st.lastActivity = time.Now()
}
if en.Auth.Name() == "hotp" {
if en.click.Clicked(gtx) {
if code, err := en.Auth.CurrentCode(); err == nil {
en.Code = code
// Surface the new counter value (not the code) so the
// user has feedback that the click registered.
if h, ok := en.Auth.(*authenticator.HOTPAuthenticator); ok {
st.toast.Show(fmt.Sprintf(i18n.T("msg_hotp_advanced"), h.Counter), w)
}
st.lastActivity = time.Now()
// Counter advanced — persist so a restart does not
// reuse the same counter value.
go st.store.Push()
@@ -276,6 +473,30 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
if copyTarget != nil {
st.copyCodeToClipboard(copyTarget, w)
}
if moreTargetIdx >= 0 {
st.mu.Lock()
total := len(st.entries)
st.mu.Unlock()
st.rowTargetIdx = moreTargetIdx
st.rowMenu = newRowActionMenu(moreTargetIdx, total)
}
// First-run welcome dialog. Routed before all other dialogs so the
// encryption choice appears immediately on a fresh install.
if st.welcomeDlg != nil {
return st.welcomeDlg.Layout(gtx, th, func(r welcomeResult) {
st.welcomeDlg = nil
if r.encrypt {
// Hand off to the existing setPasswordDialog. OK on
// that dialog will call store.SetPassword, which
// creates the config file.
st.setPwDialog = newSetPasswordDialog()
}
// skip: leave entries empty and unencrypted; the user
// can encrypt later via Settings → Set password.
w.Invalidate()
})
}
// Password retry / first-decrypt loop.
if st.pwDialog != nil {
@@ -312,16 +533,34 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
})
}
if st.changePwDlg != nil {
return st.changePwDlg.Layout(gtx, th,
func(oldPw []byte) bool { return st.store.VerifyPassword(oldPw) },
func(r changePasswordResult) {
if !r.cancel {
st.store.SetPassword(r.newPw)
st.toast.Show(i18n.T("msg_password_changed"), w)
}
st.changePwDlg = nil
w.Invalidate()
})
}
if st.settingsMenu != nil {
if act, closed := st.settingsMenu.Pick(gtx); closed {
st.settingsMenu = nil
switch act {
case settingsActionSetPassword:
st.setPwDialog = newSetPasswordDialog()
case settingsActionChangePassword:
st.changePwDlg = newChangePasswordDialog()
case settingsActionImportLegacy:
st.importDialog = newImportLegacyDialog()
case settingsActionPreferences:
lang, theme, autoLock, minToTray := st.store.Preferences()
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock, minToTray)
case settingsActionAbout:
// TODO: about dialog (next phase).
st.aboutDialog = newAboutDialog()
}
w.Invalidate()
} else {
@@ -329,6 +568,33 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
}
}
if st.prefsDialog != nil {
return st.prefsDialog.Layout(gtx, th, func(r preferencesResult) {
if !r.cancel {
_, _, _, prevMinToTray := st.store.Preferences()
st.store.SetPreferences(r.language, string(r.theme), r.autoLockMinutes, r.minimizeToTray)
i18n.SetLanguage(r.language)
st.themeMode = r.theme
st.theme = nil // force rebuild on next frame
// Re-arm or release the WM_CLOSE hook when the user
// flips the minimize-to-tray preference. Hot-reloading
// avoids forcing a restart for a setting change.
if prevMinToTray != r.minimizeToTray && st.tray != nil {
st.tray.setMinimizeToTray(r.minimizeToTray)
}
}
st.prefsDialog = nil
w.Invalidate()
})
}
if st.aboutDialog != nil {
return st.aboutDialog.Layout(gtx, th, func() {
st.aboutDialog = nil
w.Invalidate()
})
}
if st.importDialog != nil {
return st.importDialog.Layout(gtx, th, func(r importLegacyResult) {
if !r.cancel && r.cfg != nil {
@@ -372,8 +638,13 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
if added != nil {
st.mu.Lock()
st.entries = append(st.entries, &entry{Name: name, Auth: added})
idx := len(st.entries) - 1
st.mu.Unlock()
st.store.Push()
// Auto-open icon picker so the user can choose a brand
// icon for the entry right after adding it.
st.rowTargetIdx = idx
st.iconPickerDlg = newIconPickerDialog("")
}
st.dialog = nil
w.Invalidate()
@@ -396,6 +667,77 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
})
}
// Per-row action popup. Must be resolved before the entry list
// renders so the popup overlays the main UI without interference.
if st.rowMenu != nil {
action, closed := st.rowMenu.Pick(gtx)
if closed {
idx := st.rowTargetIdx
st.rowMenu = nil
switch action {
case rowActionRename:
st.mu.Lock()
if idx >= 0 && idx < len(st.entries) {
st.renameDlg = newRenameDialog(st.entries[idx].Name)
}
st.mu.Unlock()
case rowActionDelete:
st.mu.Lock()
if idx >= 0 && idx < len(st.entries) {
st.confirmDelDlg = newConfirmDeleteDialog(st.entries[idx].Name)
}
st.mu.Unlock()
case rowActionMoveUp:
st.moveEntry(idx, -1)
case rowActionMoveDown:
st.moveEntry(idx, +1)
case rowActionChangeIcon:
st.mu.Lock()
current := ""
if idx >= 0 && idx < len(st.entries) {
current = st.entries[idx].IconKey
}
st.mu.Unlock()
st.iconPickerDlg = newIconPickerDialog(current)
}
w.Invalidate()
} else {
return st.rowMenu.Layout(gtx, th)
}
}
if st.renameDlg != nil {
return st.renameDlg.Layout(gtx, th, func(name string, cancel bool) {
if !cancel {
st.renameEntry(st.rowTargetIdx, name)
}
st.renameDlg = nil
w.Invalidate()
})
}
if st.confirmDelDlg != nil {
return st.confirmDelDlg.Layout(gtx, th, func(confirmed bool) {
if confirmed {
st.deleteEntry(st.rowTargetIdx)
}
st.confirmDelDlg = nil
w.Invalidate()
})
}
if st.iconPickerDlg != nil {
return st.iconPickerDlg.Layout(gtx, th, func(iconKey string, cancel bool) {
if !cancel {
st.mu.Lock()
if st.rowTargetIdx >= 0 && st.rowTargetIdx < len(st.entries) {
st.entries[st.rowTargetIdx].IconKey = iconKey
}
st.mu.Unlock()
st.store.Push()
}
st.iconPickerDlg = nil
w.Invalidate()
})
}
if st.tradesDialog != nil {
return st.tradesDialog.Layout(gtx, th)
}
@@ -427,7 +769,7 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
return layout.Dimensions{}
}
lbl := material.Body2(th, msg)
lbl.Color = color.NRGBA{R: 0xc0, A: 0xff}
lbl.Color = activePalette.ErrorFg
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, lbl.Layout)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
@@ -485,9 +827,13 @@ func entryRow(gtx layout.Context, th *material.Theme, en *entry) layout.Dimensio
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx,
material.Button(th, &en.copyBtn, i18n.T("btn_copy")).Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx,
material.Button(th, &en.moreBtn, i18n.T("btn_more")).Layout)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.H6(th, en.Code)
lbl.Color = color.NRGBA{R: 0x10, G: 0x70, B: 0xff, A: 0xff}
lbl.Color = activePalette.RingFg
return lbl.Layout(gtx)
}),
)
+53
View File
@@ -0,0 +1,53 @@
# 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. The
workflow is:
```
# 1. Regenerate app_icon.png (optional — only if you change the source)
go run ./tools/gen_icons
# 2. Bake a multi-resolution app_icon.ico from the PNG
go run ./tools/gen_ico
# 3. Compile the .ico into a COFF .syso that Go auto-links
GOPROXY=https://goproxy.cn,direct go run github.com/akavel/rsrc@latest \
-ico internal/ui/assets/app_icon.ico \
-o internal/ui/assets/rsrc_windows.syso
```
`rsrc_windows.syso` lives next to other assets so `go build` picks it up
without any extra build-tag wiring. The tray runtime loads the icon by
resource id 1 (the first (and only) icon rsrc emits); if loading fails
it falls back to `IDI_APPLICATION`.
To regenerate the placeholders, run:
```
go run ./tools/gen_icons
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Some files were not shown because too many files have changed in this diff Show More