Compare commits
8 Commits
c671f2115e
...
133091a32e
| Author | SHA1 | Date | |
|---|---|---|---|
|
133091a32e
|
|||
|
727be3557a
|
|||
|
969600b6ee
|
|||
|
352b1d24b8
|
|||
|
c0dd3b9028
|
|||
|
0918fd446a
|
|||
|
fedc198452
|
|||
|
b483360778
|
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "选择图标"
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 973 B |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 157 B |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 783 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 731 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 913 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 326 B |
|
After Width: | Height: | Size: 342 B |
|
After Width: | Height: | Size: 175 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 881 B |
|
After Width: | Height: | Size: 627 B |
|
After Width: | Height: | Size: 1008 B |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 977 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 861 B |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 1.8 KiB |