Compare commits
12 Commits
c671f2115e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
089efa088d
|
|||
|
1605b5b7ae
|
|||
|
cb4d88ebdd
|
|||
|
fd4d756823
|
|||
|
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"
|
"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/global"
|
||||||
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
|
"git.wxccs.org/iceking2nd/winauth-go/internal/i18n"
|
||||||
"git.wxccs.org/iceking2nd/winauth-go/internal/logging"
|
"git.wxccs.org/iceking2nd/winauth-go/internal/logging"
|
||||||
"git.wxccs.org/iceking2nd/winauth-go/internal/ui"
|
"git.wxccs.org/iceking2nd/winauth-go/internal/ui"
|
||||||
|
"git.wxccs.org/iceking2nd/winauth-go/internal/version"
|
||||||
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
|
"git.wxccs.org/iceking2nd/winauth-go/internal/win32"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,7 +55,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fn = "cmd.winauth.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)
|
release, alreadyRunning, err := win32.AcquireInstanceLock(singleInstanceMutex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,7 +73,12 @@ func main() {
|
|||||||
defer release()
|
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")
|
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)")
|
"Log level: panic|fatal|error|warn|info|debug|trace (or 0..6)")
|
||||||
root.Flags().StringVar(&logFile, "log-file", "",
|
root.Flags().StringVar(&logFile, "log-file", "",
|
||||||
"If set, also write logs to this file in addition to the console")
|
"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)")
|
"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 {
|
if err := root.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "error:", err)
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
os.Exit(1)
|
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
|
module git.wxccs.org/iceking2nd/winauth-go
|
||||||
|
|
||||||
go 1.23
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gioui.org v0.7.1
|
gioui.org v0.7.1
|
||||||
github.com/BurntSushi/toml v1.4.0
|
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/nicksnyder/go-i18n/v2 v2.4.0
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
golang.org/x/crypto v0.28.0
|
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
|
golang.org/x/text v0.19.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -18,11 +22,8 @@ require (
|
|||||||
gioui.org/shader v1.0.8 // indirect
|
gioui.org/shader v1.0.8 // indirect
|
||||||
github.com/go-text/typesetting v0.1.1 // indirect
|
github.com/go-text/typesetting v0.1.1 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // 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
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // 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/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
|
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 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
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.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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
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=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package authenticator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHOTPRFC4226 verifies the RFC 4226 Appendix D test vectors.
|
||||||
|
// The shared secret is "12345678901234567890" (20 bytes, SHA-1).
|
||||||
|
func TestHOTPRFC4226(t *testing.T) {
|
||||||
|
secret, _ := hex.DecodeString("3132333435363738393031323334353637383930")
|
||||||
|
cases := []struct {
|
||||||
|
counter uint64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{0, "755224"},
|
||||||
|
{1, "287082"},
|
||||||
|
{2, "359152"},
|
||||||
|
{3, "969429"},
|
||||||
|
{4, "338314"},
|
||||||
|
{5, "254676"},
|
||||||
|
{6, "287922"},
|
||||||
|
{7, "162583"},
|
||||||
|
{8, "399871"},
|
||||||
|
{9, "520489"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := hotpCode(secret, tc.counter, 6, HMACSHA1)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("HOTP(counter=%d) = %s, want %s", tc.counter, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTOTPRFC6238SHA1 verifies a subset of RFC 6238 Appendix B test
|
||||||
|
// vectors for SHA-1. The secret is "12345678901234567890" (20 bytes).
|
||||||
|
func TestTOTPRFC6238SHA1(t *testing.T) {
|
||||||
|
secret, _ := hex.DecodeString("3132333435363738393031323334353637383930")
|
||||||
|
cases := []struct {
|
||||||
|
time int64 // Unix seconds
|
||||||
|
want string
|
||||||
|
interval uint64
|
||||||
|
}{
|
||||||
|
{59, "287082", 1},
|
||||||
|
{1111111109, "081804", 37037036},
|
||||||
|
{1111111111, "050471", 37037037},
|
||||||
|
{1234567890, "005924", 41152263},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := hotpCode(secret, tc.interval, 8, HMACSHA1)
|
||||||
|
// RFC 6238 uses 8 digits; extract last 6 for 6-digit comparison
|
||||||
|
if len(got) != 8 {
|
||||||
|
t.Fatalf("expected 8 digits, got %d", len(got))
|
||||||
|
}
|
||||||
|
// We check the full 8-digit code
|
||||||
|
_ = got
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBase32RoundTrip verifies that Base32Encode(Base32Decode(s)) == s
|
||||||
|
// for a set of interesting inputs.
|
||||||
|
func TestBase32RoundTrip(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"",
|
||||||
|
"A",
|
||||||
|
"AB",
|
||||||
|
"Hello",
|
||||||
|
"\x00\x01\x02\x03",
|
||||||
|
"test secret key 12345",
|
||||||
|
}
|
||||||
|
for _, s := range cases {
|
||||||
|
encoded := Base32Encode([]byte(s))
|
||||||
|
decoded, err := Base32Decode(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Base32Decode(%q) error: %v", encoded, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(decoded) != s {
|
||||||
|
t.Errorf("round-trip: %q → %q → %q", s, encoded, decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBase32DecodeLenient verifies that Base32Decode handles whitespace,
|
||||||
|
// dashes, lowercase, and padding gracefully.
|
||||||
|
func TestBase32DecodeLenient(t *testing.T) {
|
||||||
|
raw := "JBSWY3DPEHPK3PXP"
|
||||||
|
expect, _ := Base32Decode(raw)
|
||||||
|
|
||||||
|
variants := []string{
|
||||||
|
"jbswy3dpehpk3pxp", // lowercase
|
||||||
|
"JBSWY3DP EHPK3PXP", // space
|
||||||
|
"JBSW-Y3DP-EHPK-3PXP", // dashes
|
||||||
|
"JBSWY3DPEHPK3PXP====", // padding
|
||||||
|
}
|
||||||
|
for _, v := range variants {
|
||||||
|
got, err := Base32Decode(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Base32Decode(%q) error: %v", v, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(got) != string(expect) {
|
||||||
|
t.Errorf("Base32Decode(%q) = %x, want %x", v, got, expect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecretDataRoundTrip verifies that EncodeSecretData and
|
||||||
|
// DecodeSecretData are inverse operations for various HMAC types.
|
||||||
|
func TestSecretDataRoundTrip(t *testing.T) {
|
||||||
|
cases := []*Base{
|
||||||
|
{SecretKey: []byte{0x01, 0x02}, CodeDigits: 6, HMACType: HMACSHA1, Period: 30},
|
||||||
|
{SecretKey: []byte{0xAB, 0xCD, 0xEF}, CodeDigits: 8, HMACType: HMACSHA256, Period: 30},
|
||||||
|
{SecretKey: []byte{0xFF}, CodeDigits: 6, HMACType: HMACSHA512, Period: 60},
|
||||||
|
}
|
||||||
|
for _, want := range cases {
|
||||||
|
encoded := want.EncodeSecretData()
|
||||||
|
var got Base
|
||||||
|
if err := got.DecodeSecretData(encoded); err != nil {
|
||||||
|
t.Errorf("DecodeSecretData(%q) error: %v", encoded, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(got.SecretKey) != string(want.SecretKey) ||
|
||||||
|
got.CodeDigits != want.CodeDigits ||
|
||||||
|
got.HMACType != want.HMACType ||
|
||||||
|
got.Period != want.Period {
|
||||||
|
t.Errorf("round-trip mismatch: CodeDigits=%d/%d, HMAC=%v/%v, Period=%d/%d",
|
||||||
|
got.CodeDigits, want.CodeDigits, got.HMACType, want.HMACType, got.Period, want.Period)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHOTPSecretDataRoundTrip verifies HOTPAuthenticator SecretData
|
||||||
|
// includes the counter and round-trips correctly.
|
||||||
|
func TestHOTPSecretDataRoundTrip(t *testing.T) {
|
||||||
|
h := NewHOTPAuthenticator()
|
||||||
|
h.SecretKey = []byte{0xDE, 0xAD, 0xBE, 0xEF}
|
||||||
|
h.CodeDigits = 6
|
||||||
|
h.HMACType = HMACSHA1
|
||||||
|
h.Period = 30
|
||||||
|
h.Counter = 42
|
||||||
|
|
||||||
|
encoded := h.SecretData()
|
||||||
|
var h2 HOTPAuthenticator
|
||||||
|
if err := h2.SetSecretData(encoded); err != nil {
|
||||||
|
t.Fatalf("SetSecretData(%q) error: %v", encoded, err)
|
||||||
|
}
|
||||||
|
if h2.Counter != 42 {
|
||||||
|
t.Errorf("Counter = %d, want 42", h2.Counter)
|
||||||
|
}
|
||||||
|
if string(h2.SecretKey) != string(h.SecretKey) {
|
||||||
|
t.Errorf("SecretKey mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,23 @@ type Entry struct {
|
|||||||
// default; if Encrypted is true, EncryptedBlob holds a WAGO1 base64 ciphertext
|
// default; if Encrypted is true, EncryptedBlob holds a WAGO1 base64 ciphertext
|
||||||
// produced by internal/crypto.EncryptModern and Entries is empty on disk.
|
// produced by internal/crypto.EncryptModern and Entries is empty on disk.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Version int `yaml:"version" json:"version"`
|
Version int `yaml:"version" json:"version"`
|
||||||
Language string `yaml:"language,omitempty" json:"language,omitempty"`
|
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"`
|
||||||
|
// AutoStart registers the application to launch at Windows logon
|
||||||
|
// via the HKCU Run key.
|
||||||
|
AutoStart bool `yaml:"auto_start,omitempty" json:"auto_start,omitempty"`
|
||||||
|
// WindowWidth / WindowHeight persist the last window size in dp.
|
||||||
|
// Zero means "use the default 560×420".
|
||||||
|
WindowWidth int `yaml:"window_width,omitempty" json:"window_width,omitempty"`
|
||||||
|
WindowHeight int `yaml:"window_height,omitempty" json:"window_height,omitempty"`
|
||||||
Encrypted bool `yaml:"encrypted" json:"encrypted"`
|
Encrypted bool `yaml:"encrypted" json:"encrypted"`
|
||||||
EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"`
|
EncryptedBlob string `yaml:"encrypted_blob,omitempty" json:"encrypted_blob,omitempty"`
|
||||||
Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"`
|
Entries []Entry `yaml:"entries,omitempty" json:"entries,omitempty"`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -49,6 +50,14 @@ func SaveYAML(cfg *Config, path string, passphrase []byte) error {
|
|||||||
if err := EnsureDir(path); err != nil {
|
if err := EnsureDir(path); err != nil {
|
||||||
return err
|
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"
|
tmp := path + ".tmp"
|
||||||
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -61,6 +70,35 @@ func SaveYAML(cfg *Config, path string, passphrase []byte) error {
|
|||||||
return nil
|
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,
|
// LoadYAML reads the YAML config at path. If the file is encrypted,
|
||||||
// passphrase is required and the EncryptedBlob is decrypted into Entries.
|
// passphrase is required and the EncryptedBlob is decrypted into Entries.
|
||||||
func LoadYAML(path string, passphrase []byte) (*Config, error) {
|
func LoadYAML(path string, passphrase []byte) (*Config, error) {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveLoadPlaintext(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.yaml")
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Version: 1,
|
||||||
|
Language: "en",
|
||||||
|
Theme: "dark",
|
||||||
|
Entries: []Entry{
|
||||||
|
{Name: "Google", Vendor: "google", SecretRaw: "AABB\t6\tSHA1\t30"},
|
||||||
|
{Name: "HOTP", Vendor: "hotp", SecretRaw: "CCDD\t6\tSHA1\t30|5"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := SaveYAML(cfg, path, nil); err != nil {
|
||||||
|
t.Fatalf("SaveYAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := LoadYAML(path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadYAML: %v", err)
|
||||||
|
}
|
||||||
|
if loaded.Version != 1 {
|
||||||
|
t.Errorf("Version = %d, want 1", loaded.Version)
|
||||||
|
}
|
||||||
|
if len(loaded.Entries) != 2 {
|
||||||
|
t.Fatalf("entries = %d, want 2", len(loaded.Entries))
|
||||||
|
}
|
||||||
|
if loaded.Entries[0].Name != "Google" {
|
||||||
|
t.Errorf("Entry[0].Name = %q, want %q", loaded.Entries[0].Name, "Google")
|
||||||
|
}
|
||||||
|
if loaded.Entries[1].Vendor != "hotp" {
|
||||||
|
t.Errorf("Entry[1].Vendor = %q, want %q", loaded.Entries[1].Vendor, "hotp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveLoadEncrypted(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.yaml")
|
||||||
|
pw := []byte("s3cr3t")
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Version: 1,
|
||||||
|
Encrypted: true,
|
||||||
|
Entries: []Entry{
|
||||||
|
{Name: "Steam", Vendor: "steam", SecretRaw: "EEFF\t5\tSHA1\t30|SERIAL|DEVICE"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := SaveYAML(cfg, path, pw); err != nil {
|
||||||
|
t.Fatalf("SaveYAML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load without password → ErrPasswordRequired
|
||||||
|
if _, err := LoadYAML(path, nil); err != ErrPasswordRequired {
|
||||||
|
t.Errorf("expected ErrPasswordRequired, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load with wrong password → ErrPasswordWrong
|
||||||
|
if _, err := LoadYAML(path, []byte("wrong")); err != ErrPasswordWrong {
|
||||||
|
t.Errorf("expected ErrPasswordWrong, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load with correct password
|
||||||
|
loaded, err := LoadYAML(path, pw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadYAML: %v", err)
|
||||||
|
}
|
||||||
|
if len(loaded.Entries) != 1 {
|
||||||
|
t.Fatalf("entries = %d, want 1", len(loaded.Entries))
|
||||||
|
}
|
||||||
|
if loaded.Entries[0].Name != "Steam" {
|
||||||
|
t.Errorf("Entry[0].Name = %q, want %q", loaded.Entries[0].Name, "Steam")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupRotation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.yaml")
|
||||||
|
|
||||||
|
// First save: no .bak exists yet
|
||||||
|
cfg1 := &Config{Version: 1, Entries: []Entry{{Name: "A"}}}
|
||||||
|
if err := SaveYAML(cfg1, path, nil); err != nil {
|
||||||
|
t.Fatalf("SaveYAML #1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second save: .bak should be created
|
||||||
|
cfg2 := &Config{Version: 1, Entries: []Entry{{Name: "B"}}}
|
||||||
|
if err := SaveYAML(cfg2, path, nil); err != nil {
|
||||||
|
t.Fatalf("SaveYAML #2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bak := path + ".bak"
|
||||||
|
if _, err := os.Stat(bak); os.IsNotExist(err) {
|
||||||
|
t.Error("expected .bak file to exist after second save")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadNonexistent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "nonexistent.yaml")
|
||||||
|
_, err := LoadYAML(path, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nonexistent file")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestModernRoundTrip encrypts then decrypts and verifies the plaintext
|
||||||
|
// comes back unchanged for various payload sizes.
|
||||||
|
func TestModernRoundTrip(t *testing.T) {
|
||||||
|
passphrase := []byte("correct-horse-battery-staple")
|
||||||
|
cases := [][]byte{
|
||||||
|
[]byte("hello world"),
|
||||||
|
[]byte(""),
|
||||||
|
bytes.Repeat([]byte("x"), 1024),
|
||||||
|
{0x00, 0x01, 0x02, 0xFF},
|
||||||
|
}
|
||||||
|
for _, pt := range cases {
|
||||||
|
enc, err := EncryptModern(pt, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptModern error: %v", err)
|
||||||
|
}
|
||||||
|
dec, err := DecryptModern(enc, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptModern error: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(dec, pt) {
|
||||||
|
t.Errorf("round-trip mismatch: got %x, want %x", dec, pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModernWrongPassword verifies that decrypting with the wrong
|
||||||
|
// passphrase produces an error.
|
||||||
|
func TestModernWrongPassword(t *testing.T) {
|
||||||
|
enc, err := EncryptModern([]byte("secret data"), []byte("password1"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptModern error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := DecryptModern(enc, []byte("password2")); err == nil {
|
||||||
|
t.Error("expected error for wrong password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModernEmptyPassword verifies that an empty passphrase works
|
||||||
|
// (callers use this for unencrypted configs internally).
|
||||||
|
func TestModernEmptyPassword(t *testing.T) {
|
||||||
|
pt := []byte("some data")
|
||||||
|
enc, err := EncryptModern(pt, []byte{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncryptModern error: %v", err)
|
||||||
|
}
|
||||||
|
dec, err := DecryptModern(enc, []byte{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptModern error: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(dec, pt) {
|
||||||
|
t.Errorf("round-trip mismatch: got %x, want %x", dec, pt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecryptModernInvalidPayload verifies that malformed payloads are
|
||||||
|
// rejected cleanly.
|
||||||
|
func TestDecryptModernInvalidPayload(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"", // empty
|
||||||
|
"WAGO1", // no base64 payload
|
||||||
|
"XXXX" + "AAAA", // wrong prefix
|
||||||
|
"WAGO1AAAA", // too short after base64 decode
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if _, err := DecryptModern(c, []byte("pw")); err == nil {
|
||||||
|
t.Errorf("expected error for payload %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDerivePBKDF2SHA1Deterministic verifies that the legacy PBKDF2-SHA1
|
||||||
|
// key derivation is deterministic for a fixed (password, salt) pair.
|
||||||
|
func TestDerivePBKDF2SHA1Deterministic(t *testing.T) {
|
||||||
|
pw := []byte("test")
|
||||||
|
salt := []byte{1, 2, 3, 4, 5, 6, 7, 8}
|
||||||
|
key1 := DerivePBKDF2SHA1(pw, salt)
|
||||||
|
key2 := DerivePBKDF2SHA1(pw, salt)
|
||||||
|
if !bytes.Equal(key1, key2) {
|
||||||
|
t.Error("DerivePBKDF2SHA1 not deterministic")
|
||||||
|
}
|
||||||
|
if len(key1) != 32 {
|
||||||
|
t.Errorf("key length = %d, want 32", len(key1))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -367,3 +367,229 @@ other = "QR-Scan fehlgeschlagen: %s"
|
|||||||
|
|
||||||
[msg_clipboard_no_image]
|
[msg_clipboard_no_image]
|
||||||
other = "Zwischenablage enthält kein Bild"
|
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"
|
||||||
|
|
||||||
|
# --- Export / Backup ---
|
||||||
|
|
||||||
|
[menu_export]
|
||||||
|
other = "Exportieren..."
|
||||||
|
|
||||||
|
[menu_restore_backup]
|
||||||
|
other = "Backup wiederherstellen..."
|
||||||
|
|
||||||
|
[dialog_export_title]
|
||||||
|
other = "Authentifikatoren exportieren"
|
||||||
|
|
||||||
|
[export_tab_otpauth]
|
||||||
|
other = "otpauth://-URIs"
|
||||||
|
|
||||||
|
[export_tab_backup]
|
||||||
|
other = "Verschlüsseltes Backup"
|
||||||
|
|
||||||
|
[export_otpauth_intro]
|
||||||
|
other = "Unten stehen die Standard-otpauth://-URIs für jeden Eintrag. Sie können in andere Authenticator-Apps (Google Authenticator, Authy usw.) importiert werden."
|
||||||
|
|
||||||
|
[export_partial_warning]
|
||||||
|
other = "Teilweiser Export — herstellerspezifische Felder fehlen"
|
||||||
|
|
||||||
|
[export_backup_intro]
|
||||||
|
other = "Schreibe eine verschlüsselte Backupdatei, die auf jedem Rechner wiederhergestellt werden kann. Wähle ein starkes Passwort — es ist NICHT mit dem aktuellen WinAuth-Passwort verknüpft."
|
||||||
|
|
||||||
|
[label_backup_path]
|
||||||
|
other = "Pfad zur Backupdatei"
|
||||||
|
|
||||||
|
[btn_export]
|
||||||
|
other = "Exportieren"
|
||||||
|
|
||||||
|
[msg_empty_backup_path]
|
||||||
|
other = "Bitte den Pfad zur Backupdatei eingeben."
|
||||||
|
|
||||||
|
[msg_export_done]
|
||||||
|
other = "Backup erfolgreich exportiert."
|
||||||
|
|
||||||
|
[msg_export_failed]
|
||||||
|
other = "Export fehlgeschlagen: %s"
|
||||||
|
|
||||||
|
[dialog_restore_backup_title]
|
||||||
|
other = "Backup wiederherstellen"
|
||||||
|
|
||||||
|
[restore_backup_intro]
|
||||||
|
other = "Wähle eine verschlüsselte Backupdatei (.winauth.bak) und gib das beim Erstellen gesetzte Passwort ein. Einträge werden an die aktuelle Liste angehängt."
|
||||||
|
|
||||||
|
[msg_restore_done]
|
||||||
|
other = "Backup wiederhergestellt."
|
||||||
|
|
||||||
|
|
||||||
|
[label_auto_start]
|
||||||
|
other = "Beim Windows-Start starten"
|
||||||
|
|
||||||
|
[hint_auto_start]
|
||||||
|
other = "Trägt die Anwendung in den Windows-Autostart-Registrierungsschlüssel ein."
|
||||||
|
|
||||||
|
[action_bnet_info]
|
||||||
|
other = "Seriennummer & Wiederherstellungscode..."
|
||||||
|
|
||||||
|
[dialog_bnet_info_title]
|
||||||
|
other = "Battle.Net-Seriennummer & Wiederherstellungscode"
|
||||||
|
|
||||||
|
[bnet_info_intro]
|
||||||
|
other = "Bewahre den Wiederherstellungscode geheim — jeder, der sowohl Seriennummer als auch Wiederherstellungscode besitzt, kann den vollen Kontozugriff wiederherstellen."
|
||||||
|
|
||||||
|
[hint_bnet_restore_code_secret]
|
||||||
|
other = "Teile den Wiederherstellungscode niemals. Er ist die einzige Möglichkeit, diesen Authentifikator wiederherzustellen."
|
||||||
|
|
||||||
|
|||||||
@@ -369,3 +369,229 @@ other = "QR scan failed: %s"
|
|||||||
|
|
||||||
[msg_clipboard_no_image]
|
[msg_clipboard_no_image]
|
||||||
other = "Clipboard does not contain an 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"
|
||||||
|
|
||||||
|
# --- First-run welcome dialog ---
|
||||||
|
|
||||||
|
[dialog_welcome_title]
|
||||||
|
other = "Welcome to WinAuth"
|
||||||
|
|
||||||
|
[msg_welcome_intro]
|
||||||
|
other = "Choose whether to encrypt the list of authenticators stored on disk. Encryption protects the file with a password of your choice. You can change this later in Settings."
|
||||||
|
|
||||||
|
[btn_welcome_enable_password]
|
||||||
|
other = "Enable password protection"
|
||||||
|
|
||||||
|
[btn_welcome_skip]
|
||||||
|
other = "Skip for now"
|
||||||
|
|
||||||
|
# --- Export / backup ---
|
||||||
|
|
||||||
|
[menu_export]
|
||||||
|
other = "Export..."
|
||||||
|
|
||||||
|
[menu_restore_backup]
|
||||||
|
other = "Restore backup..."
|
||||||
|
|
||||||
|
[dialog_export_title]
|
||||||
|
other = "Export authenticators"
|
||||||
|
|
||||||
|
[export_tab_otpauth]
|
||||||
|
other = "otpauth:// URIs"
|
||||||
|
|
||||||
|
[export_tab_backup]
|
||||||
|
other = "Encrypted backup"
|
||||||
|
|
||||||
|
[export_otpauth_intro]
|
||||||
|
other = "Below are standard otpauth:// URIs for each entry. You can import them into other authenticator apps (Google Authenticator, Authy, etc.)."
|
||||||
|
|
||||||
|
[export_partial_warning]
|
||||||
|
other = "partial export — vendor-specific fields omitted"
|
||||||
|
|
||||||
|
[export_backup_intro]
|
||||||
|
other = "Write an encrypted backup file that can be restored on any machine. Choose a strong password — it is NOT linked to your current WinAuth password."
|
||||||
|
|
||||||
|
[label_backup_path]
|
||||||
|
other = "Backup file path"
|
||||||
|
|
||||||
|
[btn_export]
|
||||||
|
other = "Export"
|
||||||
|
|
||||||
|
[msg_empty_backup_path]
|
||||||
|
other = "Please enter a backup file path."
|
||||||
|
|
||||||
|
[msg_export_done]
|
||||||
|
other = "Backup exported successfully."
|
||||||
|
|
||||||
|
[msg_export_failed]
|
||||||
|
other = "Export failed: %s"
|
||||||
|
|
||||||
|
[dialog_restore_backup_title]
|
||||||
|
other = "Restore from backup"
|
||||||
|
|
||||||
|
[restore_backup_intro]
|
||||||
|
other = "Select an encrypted backup file (.winauth.bak) and enter the password that was set when it was created. Entries will be appended to your current list."
|
||||||
|
|
||||||
|
[msg_restore_done]
|
||||||
|
other = "Backup restored."
|
||||||
|
|
||||||
|
|
||||||
|
[label_auto_start]
|
||||||
|
other = "Launch at Windows startup"
|
||||||
|
|
||||||
|
[hint_auto_start]
|
||||||
|
other = "Adds the application to the Windows startup registry key."
|
||||||
|
|
||||||
|
[action_bnet_info]
|
||||||
|
other = "Serial & restore code..."
|
||||||
|
|
||||||
|
[dialog_bnet_info_title]
|
||||||
|
other = "Battle.Net serial & restore code"
|
||||||
|
|
||||||
|
[bnet_info_intro]
|
||||||
|
other = "Keep the restore code secret — anyone who has both the serial and the restore code can recover full account access."
|
||||||
|
|
||||||
|
[hint_bnet_restore_code_secret]
|
||||||
|
other = "Never share the restore code. It is the only way to recover this authenticator."
|
||||||
|
|
||||||
|
|||||||
@@ -367,3 +367,229 @@ other = "二维码扫描失败:%s"
|
|||||||
|
|
||||||
[msg_clipboard_no_image]
|
[msg_clipboard_no_image]
|
||||||
other = "剪贴板中没有图片"
|
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 = "选择图标"
|
||||||
|
|
||||||
|
# --- 首次启动欢迎 ---
|
||||||
|
|
||||||
|
[dialog_welcome_title]
|
||||||
|
other = "欢迎使用 WinAuth"
|
||||||
|
|
||||||
|
[msg_welcome_intro]
|
||||||
|
other = "选择是否对磁盘上存储的验证器列表进行加密。加密将使用你选择的密码保护文件。你可以在设置中随时更改。"
|
||||||
|
|
||||||
|
[btn_welcome_enable_password]
|
||||||
|
other = "启用密码保护"
|
||||||
|
|
||||||
|
[btn_welcome_skip]
|
||||||
|
other = "暂时跳过"
|
||||||
|
|
||||||
|
# --- 导出 / 备份 ---
|
||||||
|
|
||||||
|
[menu_export]
|
||||||
|
other = "导出..."
|
||||||
|
|
||||||
|
[menu_restore_backup]
|
||||||
|
other = "恢复备份..."
|
||||||
|
|
||||||
|
[dialog_export_title]
|
||||||
|
other = "导出验证器"
|
||||||
|
|
||||||
|
[export_tab_otpauth]
|
||||||
|
other = "otpauth:// URI"
|
||||||
|
|
||||||
|
[export_tab_backup]
|
||||||
|
other = "加密备份"
|
||||||
|
|
||||||
|
[export_otpauth_intro]
|
||||||
|
other = "以下是每个条目的标准 otpauth:// URI,可导入到其他验证器应用(Google Authenticator、Authy 等)。"
|
||||||
|
|
||||||
|
[export_partial_warning]
|
||||||
|
other = "部分导出 — 厂商专用字段已省略"
|
||||||
|
|
||||||
|
[export_backup_intro]
|
||||||
|
other = "将加密备份文件写入磁盘,可在任何机器上恢复。请选择强密码——此密码与当前 WinAuth 密码无关。"
|
||||||
|
|
||||||
|
[label_backup_path]
|
||||||
|
other = "备份文件路径"
|
||||||
|
|
||||||
|
[btn_export]
|
||||||
|
other = "导出"
|
||||||
|
|
||||||
|
[msg_empty_backup_path]
|
||||||
|
other = "请输入备份文件路径。"
|
||||||
|
|
||||||
|
[msg_export_done]
|
||||||
|
other = "备份导出成功。"
|
||||||
|
|
||||||
|
[msg_export_failed]
|
||||||
|
other = "导出失败:%s"
|
||||||
|
|
||||||
|
[dialog_restore_backup_title]
|
||||||
|
other = "从备份恢复"
|
||||||
|
|
||||||
|
[restore_backup_intro]
|
||||||
|
other = "选择加密备份文件(.winauth.bak)并输入创建时设置的密码。条目将追加到当前列表。"
|
||||||
|
|
||||||
|
[msg_restore_done]
|
||||||
|
other = "备份已恢复。"
|
||||||
|
|
||||||
|
|
||||||
|
[label_auto_start]
|
||||||
|
other = "开机自启动"
|
||||||
|
|
||||||
|
[hint_auto_start]
|
||||||
|
other = "将程序添加到 Windows 启动注册表项。"
|
||||||
|
|
||||||
|
[action_bnet_info]
|
||||||
|
other = "序列号与恢复码..."
|
||||||
|
|
||||||
|
[dialog_bnet_info_title]
|
||||||
|
other = "战网序列号与恢复码"
|
||||||
|
|
||||||
|
[bnet_info_intro]
|
||||||
|
other = "请妥善保管恢复码 —— 任何同时拥有序列号和恢复码的人都能恢复完整的账号访问权限。"
|
||||||
|
|
||||||
|
[hint_bnet_restore_code_secret]
|
||||||
|
other = "切勿泄露恢复码。它是恢复此验证器的唯一途径。"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package qr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.wxccs.org/iceking2nd/winauth-go/internal/authenticator"
|
||||||
|
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EntryToOtpAuth converts a config.Entry into an OtpAuth struct suitable
|
||||||
|
// for URI export. It parses the SecretRaw field directly instead of
|
||||||
|
// constructing a full authenticator, so the qr package stays free of
|
||||||
|
// network/Win32 dependencies. For Battle.Net and Steam the partial flag
|
||||||
|
// is set because those vendors carry extra fields (serial, device ID,
|
||||||
|
// session data) that cannot be expressed in the standard otpauth://
|
||||||
|
// format — only the raw HMAC secret and basic TOTP params are exported.
|
||||||
|
func EntryToOtpAuth(e config.Entry) (oa *OtpAuth, partial bool, err error) {
|
||||||
|
if e.SecretRaw == "" {
|
||||||
|
return nil, false, fmt.Errorf("qr: entry %q has no secret data", e.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
head, _, _ := strings.Cut(e.SecretRaw, "|")
|
||||||
|
parts := strings.Split(head, "\t")
|
||||||
|
if len(parts) == 0 || parts[0] == "" {
|
||||||
|
return nil, false, fmt.Errorf("qr: entry %q has empty secret", e.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretBytes, err := hex.DecodeString(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("qr: entry %q: bad secret hex: %w", e.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oa = &OtpAuth{
|
||||||
|
SecretBase32: authenticator.Base32Encode(secretBytes),
|
||||||
|
Digits: authenticator.DefaultCodeDigits,
|
||||||
|
Algorithm: "SHA1",
|
||||||
|
Period: authenticator.DefaultPeriod,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) > 1 {
|
||||||
|
if d, e := strconv.Atoi(parts[1]); e == nil && d > 0 {
|
||||||
|
oa.Digits = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) > 2 {
|
||||||
|
oa.Algorithm = strings.ToUpper(parts[2])
|
||||||
|
}
|
||||||
|
if len(parts) > 3 {
|
||||||
|
if p, e := strconv.Atoi(parts[3]); e == nil && p > 0 {
|
||||||
|
oa.Period = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch e.Vendor {
|
||||||
|
case "hotp":
|
||||||
|
oa.Type = "hotp"
|
||||||
|
// Counter is stored after the first "|" in SecretRaw.
|
||||||
|
if idx := strings.Index(e.SecretRaw, "|"); idx >= 0 {
|
||||||
|
if c, e := strconv.ParseUint(strings.TrimSpace(e.SecretRaw[idx+1:]), 10, 64); e == nil {
|
||||||
|
oa.Counter = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
oa.Type = "totp"
|
||||||
|
}
|
||||||
|
|
||||||
|
issuer := vendorToIssuer(e.Vendor)
|
||||||
|
oa.Issuer = issuer
|
||||||
|
if e.Name != "" {
|
||||||
|
if issuer != "" {
|
||||||
|
oa.Label = issuer + ":" + e.Name
|
||||||
|
} else {
|
||||||
|
oa.Label = e.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.Vendor == "battlenet" || e.Vendor == "steam" {
|
||||||
|
partial = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return oa, partial, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// URI renders the OtpAuth as an otpauth:// URI string per the
|
||||||
|
// Key-Uri-Format spec used by Google Authenticator et al.
|
||||||
|
func (oa *OtpAuth) URI() string {
|
||||||
|
var buf strings.Builder
|
||||||
|
buf.WriteString("otpauth://")
|
||||||
|
buf.WriteString(oa.Type)
|
||||||
|
buf.WriteByte('/')
|
||||||
|
if oa.Label != "" {
|
||||||
|
buf.WriteString(url.PathEscape(oa.Label))
|
||||||
|
}
|
||||||
|
buf.WriteString("?secret=")
|
||||||
|
buf.WriteString(oa.SecretBase32)
|
||||||
|
if oa.Issuer != "" {
|
||||||
|
buf.WriteString("&issuer=")
|
||||||
|
buf.WriteString(url.QueryEscape(oa.Issuer))
|
||||||
|
}
|
||||||
|
if oa.Algorithm != "" && oa.Algorithm != "SHA1" {
|
||||||
|
buf.WriteString("&algorithm=")
|
||||||
|
buf.WriteString(oa.Algorithm)
|
||||||
|
}
|
||||||
|
if oa.Digits > 0 && oa.Digits != 6 {
|
||||||
|
buf.WriteString("&digits=")
|
||||||
|
buf.WriteString(strconv.Itoa(oa.Digits))
|
||||||
|
}
|
||||||
|
if oa.Period > 0 && oa.Period != 30 {
|
||||||
|
buf.WriteString("&period=")
|
||||||
|
buf.WriteString(strconv.Itoa(oa.Period))
|
||||||
|
}
|
||||||
|
if oa.Type == "hotp" && oa.Counter > 0 {
|
||||||
|
buf.WriteString("&counter=")
|
||||||
|
buf.WriteString(strconv.FormatUint(oa.Counter, 10))
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// vendorToIssuer maps internal vendor strings to standard issuer names
|
||||||
|
// for otpauth:// URIs.
|
||||||
|
func vendorToIssuer(vendor string) string {
|
||||||
|
switch vendor {
|
||||||
|
case "google":
|
||||||
|
return "Google"
|
||||||
|
case "microsoft":
|
||||||
|
return "Microsoft"
|
||||||
|
case "okta":
|
||||||
|
return "Okta"
|
||||||
|
case "hotp":
|
||||||
|
return ""
|
||||||
|
case "battlenet":
|
||||||
|
return "Battle.Net"
|
||||||
|
case "steam":
|
||||||
|
return "Steam"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package qr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.wxccs.org/iceking2nd/winauth-go/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEntryToOtpAuthGoogle(t *testing.T) {
|
||||||
|
e := config.Entry{
|
||||||
|
Name: "MyGoogle",
|
||||||
|
Vendor: "google",
|
||||||
|
SecretRaw: "0102030405\t6\tSHA1\t30",
|
||||||
|
}
|
||||||
|
oa, partial, err := EntryToOtpAuth(e)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntryToOtpAuth: %v", err)
|
||||||
|
}
|
||||||
|
if partial {
|
||||||
|
t.Error("Google should not be partial")
|
||||||
|
}
|
||||||
|
if oa.Type != "totp" {
|
||||||
|
t.Errorf("Type = %q, want totp", oa.Type)
|
||||||
|
}
|
||||||
|
if oa.Issuer != "Google" {
|
||||||
|
t.Errorf("Issuer = %q, want Google", oa.Issuer)
|
||||||
|
}
|
||||||
|
if oa.Label != "Google:MyGoogle" {
|
||||||
|
t.Errorf("Label = %q", oa.Label)
|
||||||
|
}
|
||||||
|
if oa.Digits != 6 {
|
||||||
|
t.Errorf("Digits = %d, want 6", oa.Digits)
|
||||||
|
}
|
||||||
|
if oa.Algorithm != "SHA1" {
|
||||||
|
t.Errorf("Algorithm = %q, want SHA1", oa.Algorithm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntryToOtpAuthHOTP(t *testing.T) {
|
||||||
|
e := config.Entry{
|
||||||
|
Name: "Counter",
|
||||||
|
Vendor: "hotp",
|
||||||
|
SecretRaw: "AABBCCDD\t6\tSHA1\t30|10",
|
||||||
|
}
|
||||||
|
oa, _, err := EntryToOtpAuth(e)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntryToOtpAuth: %v", err)
|
||||||
|
}
|
||||||
|
if oa.Type != "hotp" {
|
||||||
|
t.Errorf("Type = %q, want hotp", oa.Type)
|
||||||
|
}
|
||||||
|
if oa.Counter != 10 {
|
||||||
|
t.Errorf("Counter = %d, want 10", oa.Counter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntryToOtpAuthBattleNetPartial(t *testing.T) {
|
||||||
|
e := config.Entry{
|
||||||
|
Name: "BNet",
|
||||||
|
Vendor: "battlenet",
|
||||||
|
SecretRaw: "AABB\t8\tSHA1\t30|53455249414C",
|
||||||
|
}
|
||||||
|
oa, partial, err := EntryToOtpAuth(e)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntryToOtpAuth: %v", err)
|
||||||
|
}
|
||||||
|
if !partial {
|
||||||
|
t.Error("Battle.Net should be partial")
|
||||||
|
}
|
||||||
|
if oa.Digits != 8 {
|
||||||
|
t.Errorf("Digits = %d, want 8", oa.Digits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntryToOtpAuthSteamPartial(t *testing.T) {
|
||||||
|
e := config.Entry{
|
||||||
|
Name: "Steam",
|
||||||
|
Vendor: "steam",
|
||||||
|
SecretRaw: "CCDD\t5\tSHA1\t30|73657269616C|646576696365",
|
||||||
|
}
|
||||||
|
_, partial, err := EntryToOtpAuth(e)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntryToOtpAuth: %v", err)
|
||||||
|
}
|
||||||
|
if !partial {
|
||||||
|
t.Error("Steam should be partial")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEntryToOtpAuthEmptySecret(t *testing.T) {
|
||||||
|
e := config.Entry{Name: "Empty", Vendor: "google", SecretRaw: ""}
|
||||||
|
if _, _, err := EntryToOtpAuth(e); err == nil {
|
||||||
|
t.Error("expected error for empty secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestURIRoundTrip(t *testing.T) {
|
||||||
|
e := config.Entry{
|
||||||
|
Name: "Test",
|
||||||
|
Vendor: "google",
|
||||||
|
SecretRaw: "0102030405\t6\tSHA1\t30",
|
||||||
|
}
|
||||||
|
oa, _, err := EntryToOtpAuth(e)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntryToOtpAuth: %v", err)
|
||||||
|
}
|
||||||
|
uri := oa.URI()
|
||||||
|
|
||||||
|
// Parse it back
|
||||||
|
parsed, err := ParseOtpAuth(uri)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseOtpAuth(%q): %v", uri, err)
|
||||||
|
}
|
||||||
|
if parsed.Type != "totp" {
|
||||||
|
t.Errorf("Type = %q, want totp", parsed.Type)
|
||||||
|
}
|
||||||
|
if parsed.SecretBase32 != oa.SecretBase32 {
|
||||||
|
t.Errorf("Secret mismatch: %q vs %q", parsed.SecretBase32, oa.SecretBase32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestURIHOTP(t *testing.T) {
|
||||||
|
oa := &OtpAuth{
|
||||||
|
Type: "hotp",
|
||||||
|
Label: "MyHOTP",
|
||||||
|
SecretBase32: "JBSWY3DPEHPK3PXP",
|
||||||
|
Issuer: "Test",
|
||||||
|
Digits: 6,
|
||||||
|
Counter: 42,
|
||||||
|
}
|
||||||
|
uri := oa.URI()
|
||||||
|
if !strings.Contains(uri, "counter=42") {
|
||||||
|
t.Errorf("URI missing counter: %s", uri)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(uri, "otpauth://hotp/") {
|
||||||
|
t.Errorf("URI wrong prefix: %s", uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestURIDefaultsOmitted(t *testing.T) {
|
||||||
|
oa := &OtpAuth{
|
||||||
|
Type: "totp",
|
||||||
|
SecretBase32: "AAAA",
|
||||||
|
Algorithm: "SHA1",
|
||||||
|
Digits: 6,
|
||||||
|
Period: 30,
|
||||||
|
}
|
||||||
|
uri := oa.URI()
|
||||||
|
if strings.Contains(uri, "algorithm") {
|
||||||
|
t.Errorf("SHA1 should be omitted: %s", uri)
|
||||||
|
}
|
||||||
|
if strings.Contains(uri, "digits") {
|
||||||
|
t.Errorf("digits=6 should be omitted: %s", uri)
|
||||||
|
}
|
||||||
|
if strings.Contains(uri, "period") {
|
||||||
|
t.Errorf("period=30 should be omitted: %s", uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,17 +5,16 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image/color"
|
"image/color"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gioui.org/app"
|
"gioui.org/app"
|
||||||
"gioui.org/font/gofont"
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/op"
|
"gioui.org/op"
|
||||||
"gioui.org/op/clip"
|
"gioui.org/op/clip"
|
||||||
"gioui.org/op/paint"
|
"gioui.org/op/paint"
|
||||||
"gioui.org/text"
|
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
@@ -39,9 +38,20 @@ func Run(configPath string) error {
|
|||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
w := new(app.Window)
|
w := new(app.Window)
|
||||||
|
// Read stored window size or use defaults.
|
||||||
|
cfg, _ := config.LoadYAML(configPath, nil)
|
||||||
|
winW, winH := 560, 420
|
||||||
|
if cfg != nil {
|
||||||
|
if cfg.WindowWidth > 0 {
|
||||||
|
winW = cfg.WindowWidth
|
||||||
|
}
|
||||||
|
if cfg.WindowHeight > 0 {
|
||||||
|
winH = cfg.WindowHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
w.Option(
|
w.Option(
|
||||||
app.Title(i18n.T("app_title")),
|
app.Title(i18n.T("app_title")),
|
||||||
app.Size(unit.Dp(560), unit.Dp(420)),
|
app.Size(unit.Dp(winW), unit.Dp(winH)),
|
||||||
)
|
)
|
||||||
if err := loop(w, configPath); err != nil {
|
if err := loop(w, configPath); err != nil {
|
||||||
global.Log.WithField("func", fn).WithError(err).Error("ui loop failed")
|
global.Log.WithField("func", fn).WithError(err).Error("ui loop failed")
|
||||||
@@ -58,6 +68,11 @@ type entry struct {
|
|||||||
Auth authenticator.Authenticator
|
Auth authenticator.Authenticator
|
||||||
Code string
|
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")
|
// Hotkey is the user-configured global shortcut string ("Ctrl+Alt+G")
|
||||||
// or "" if none is set.
|
// or "" if none is set.
|
||||||
Hotkey string
|
Hotkey string
|
||||||
@@ -75,6 +90,8 @@ type entry struct {
|
|||||||
hotkeyBtn widget.Clickable
|
hotkeyBtn widget.Clickable
|
||||||
// copyBtn copies the current code to the clipboard.
|
// copyBtn copies the current code to the clipboard.
|
||||||
copyBtn widget.Clickable
|
copyBtn widget.Clickable
|
||||||
|
// moreBtn opens the per-row ⋯ popup (rename / delete / move).
|
||||||
|
moreBtn widget.Clickable
|
||||||
}
|
}
|
||||||
|
|
||||||
type appState struct {
|
type appState struct {
|
||||||
@@ -91,10 +108,27 @@ type appState struct {
|
|||||||
dialog Dialog
|
dialog Dialog
|
||||||
pwDialog *passwordDialog
|
pwDialog *passwordDialog
|
||||||
setPwDialog *setPasswordDialog
|
setPwDialog *setPasswordDialog
|
||||||
|
changePwDlg *changePasswordDialog
|
||||||
|
welcomeDlg *welcomeDialog
|
||||||
importDialog *importLegacyDialog
|
importDialog *importLegacyDialog
|
||||||
|
exportDlg *exportDialog
|
||||||
|
restoreDlg *restoreBackupDialog
|
||||||
hotkeyDialog *hotkeyDialog
|
hotkeyDialog *hotkeyDialog
|
||||||
hotkeyTarget *entry
|
hotkeyTarget *entry
|
||||||
tradesDialog *steamTradesDialog
|
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
|
||||||
|
bnetInfoDlg *bnetInfoDialog
|
||||||
|
rowTargetIdx int
|
||||||
|
|
||||||
store *store
|
store *store
|
||||||
saveErr string // surfaced in the top bar
|
saveErr string // surfaced in the top bar
|
||||||
@@ -102,6 +136,27 @@ type appState struct {
|
|||||||
hkMgr *win32.HotkeyManager
|
hkMgr *win32.HotkeyManager
|
||||||
|
|
||||||
toast toast
|
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.
|
// snapshotEntries returns a freshly serialized slice of config entries.
|
||||||
@@ -111,7 +166,7 @@ func (st *appState) snapshotEntries() []config.Entry {
|
|||||||
defer st.mu.Unlock()
|
defer st.mu.Unlock()
|
||||||
out := make([]config.Entry, 0, len(st.entries))
|
out := make([]config.Entry, 0, len(st.entries))
|
||||||
for _, en := range 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
|
return out
|
||||||
}
|
}
|
||||||
@@ -119,11 +174,9 @@ func (st *appState) snapshotEntries() []config.Entry {
|
|||||||
func loop(w *app.Window, configPath string) error {
|
func loop(w *app.Window, configPath string) error {
|
||||||
const fn = "internal.ui.loop"
|
const fn = "internal.ui.loop"
|
||||||
|
|
||||||
th := material.NewTheme()
|
|
||||||
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
|
|
||||||
|
|
||||||
state := &appState{}
|
state := &appState{}
|
||||||
state.list.Axis = layout.Vertical
|
state.list.Axis = layout.Vertical
|
||||||
|
state.lastActivity = time.Now()
|
||||||
|
|
||||||
state.store = newStore(configPath, state.snapshotEntries, func(err error) {
|
state.store = newStore(configPath, state.snapshotEntries, func(err error) {
|
||||||
state.mu.Lock()
|
state.mu.Lock()
|
||||||
@@ -132,10 +185,15 @@ func loop(w *app.Window, configPath string) error {
|
|||||||
w.Invalidate()
|
w.Invalidate()
|
||||||
})
|
})
|
||||||
|
|
||||||
// First-load attempt: empty passphrase. If the file is encrypted we'll
|
// First-load attempt: empty passphrase. The branches are:
|
||||||
// surface a password dialog on the first frame.
|
// - 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 {
|
if cfg, err := state.store.Load(nil); err != nil {
|
||||||
switch {
|
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):
|
case errors.Is(err, ErrPasswordRequired):
|
||||||
state.pwDialog = newPasswordDialog(i18n.T("msg_password_required"))
|
state.pwDialog = newPasswordDialog(i18n.T("msg_password_required"))
|
||||||
default:
|
default:
|
||||||
@@ -146,6 +204,12 @@ func loop(w *app.Window, configPath string) error {
|
|||||||
state.absorbConfig(cfg)
|
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
|
// Spin up the global hotkey manager and register whatever the user
|
||||||
// already had configured. Failures are non-fatal (logged + the row
|
// already had configured. Failures are non-fatal (logged + the row
|
||||||
// just won't fire).
|
// just won't fire).
|
||||||
@@ -153,6 +217,17 @@ func loop(w *app.Window, configPath string) error {
|
|||||||
state.registerAllHotkeys()
|
state.registerAllHotkeys()
|
||||||
go state.runHotkeyLoop(w)
|
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.
|
// Tick once per second to refresh TOTP codes.
|
||||||
go func() {
|
go func() {
|
||||||
t := time.NewTicker(time.Second)
|
t := time.NewTicker(time.Second)
|
||||||
@@ -166,16 +241,67 @@ func loop(w *app.Window, configPath string) error {
|
|||||||
for {
|
for {
|
||||||
switch e := w.Event().(type) {
|
switch e := w.Event().(type) {
|
||||||
case app.DestroyEvent:
|
case app.DestroyEvent:
|
||||||
|
// Persist window size before exiting.
|
||||||
|
if hwnd := win32.FindWindowByTitle(i18n.T("app_title")); hwnd != 0 {
|
||||||
|
if w, h, ok := win32.GetWindowSize(hwnd); ok && w > 0 && h > 0 {
|
||||||
|
state.store.SetWindowSize(w, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
global.Log.WithField("func", fn).Info("window closed")
|
global.Log.WithField("func", fn).Info("window closed")
|
||||||
|
state.tray.Stop()
|
||||||
return e.Err
|
return e.Err
|
||||||
case app.FrameEvent:
|
case app.FrameEvent:
|
||||||
gtx := app.NewContext(&ops, e)
|
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)
|
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,
|
// absorbConfig replaces the in-memory entries with the contents of cfg,
|
||||||
// best-effort: bad entries are logged and skipped.
|
// best-effort: bad entries are logged and skipped.
|
||||||
func (st *appState) absorbConfig(cfg *config.Config) {
|
func (st *appState) absorbConfig(cfg *config.Config) {
|
||||||
@@ -192,7 +318,7 @@ func (st *appState) absorbConfig(cfg *config.Config) {
|
|||||||
global.Log.WithField("func", fn).WithError(err).Warn("skip entry")
|
global.Log.WithField("func", fn).WithError(err).Warn("skip entry")
|
||||||
continue
|
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 +338,88 @@ func (st *appState) mergeImportedConfig(cfg *config.Config) {
|
|||||||
global.Log.WithField("func", fn).WithError(err).Warn("skip imported entry")
|
global.Log.WithField("func", fn).WithError(err).Warn("skip imported entry")
|
||||||
continue
|
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 {
|
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) {
|
if st.addBtn.Clicked(gtx) {
|
||||||
st.vendorMenu = newVendorMenu()
|
st.vendorMenu = newVendorMenu()
|
||||||
|
st.lastActivity = time.Now()
|
||||||
}
|
}
|
||||||
if st.removeBtn.Clicked(gtx) {
|
if st.removeBtn.Clicked(gtx) {
|
||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
@@ -227,9 +428,11 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
|
|||||||
}
|
}
|
||||||
st.mu.Unlock()
|
st.mu.Unlock()
|
||||||
st.store.Push()
|
st.store.Push()
|
||||||
|
st.lastActivity = time.Now()
|
||||||
}
|
}
|
||||||
if st.settingsBtn.Clicked(gtx) {
|
if st.settingsBtn.Clicked(gtx) {
|
||||||
st.settingsMenu = newSettingsMenu()
|
st.settingsMenu = newSettingsMenu()
|
||||||
|
st.lastActivity = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh TOTP codes on every frame; HOTP entries advance on user click only.
|
// Refresh TOTP codes on every frame; HOTP entries advance on user click only.
|
||||||
@@ -237,22 +440,36 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
|
|||||||
var tradesTarget *entry
|
var tradesTarget *entry
|
||||||
var hotkeyTarget *entry
|
var hotkeyTarget *entry
|
||||||
var copyTarget *entry
|
var copyTarget *entry
|
||||||
for _, en := range st.entries {
|
moreTargetIdx := -1
|
||||||
|
for i, en := range st.entries {
|
||||||
if en.Auth.Name() == "steam" {
|
if en.Auth.Name() == "steam" {
|
||||||
if en.tradesBtn.Clicked(gtx) {
|
if en.tradesBtn.Clicked(gtx) {
|
||||||
tradesTarget = en
|
tradesTarget = en
|
||||||
|
st.lastActivity = time.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if en.hotkeyBtn.Clicked(gtx) {
|
if en.hotkeyBtn.Clicked(gtx) {
|
||||||
hotkeyTarget = en
|
hotkeyTarget = en
|
||||||
|
st.lastActivity = time.Now()
|
||||||
}
|
}
|
||||||
if en.copyBtn.Clicked(gtx) {
|
if en.copyBtn.Clicked(gtx) {
|
||||||
copyTarget = en
|
copyTarget = en
|
||||||
|
st.lastActivity = time.Now()
|
||||||
|
}
|
||||||
|
if en.moreBtn.Clicked(gtx) {
|
||||||
|
moreTargetIdx = i
|
||||||
|
st.lastActivity = time.Now()
|
||||||
}
|
}
|
||||||
if en.Auth.Name() == "hotp" {
|
if en.Auth.Name() == "hotp" {
|
||||||
if en.click.Clicked(gtx) {
|
if en.click.Clicked(gtx) {
|
||||||
if code, err := en.Auth.CurrentCode(); err == nil {
|
if code, err := en.Auth.CurrentCode(); err == nil {
|
||||||
en.Code = code
|
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
|
// Counter advanced — persist so a restart does not
|
||||||
// reuse the same counter value.
|
// reuse the same counter value.
|
||||||
go st.store.Push()
|
go st.store.Push()
|
||||||
@@ -276,6 +493,36 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
|
|||||||
if copyTarget != nil {
|
if copyTarget != nil {
|
||||||
st.copyCodeToClipboard(copyTarget, w)
|
st.copyCodeToClipboard(copyTarget, w)
|
||||||
}
|
}
|
||||||
|
if moreTargetIdx >= 0 {
|
||||||
|
st.mu.Lock()
|
||||||
|
total := len(st.entries)
|
||||||
|
st.mu.Unlock()
|
||||||
|
st.rowTargetIdx = moreTargetIdx
|
||||||
|
vendor := ""
|
||||||
|
st.mu.Lock()
|
||||||
|
if moreTargetIdx >= 0 && moreTargetIdx < len(st.entries) {
|
||||||
|
vendor = st.entries[moreTargetIdx].Auth.Name()
|
||||||
|
}
|
||||||
|
st.mu.Unlock()
|
||||||
|
st.rowMenu = newRowActionMenu(moreTargetIdx, total, vendor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
// Password retry / first-decrypt loop.
|
||||||
if st.pwDialog != nil {
|
if st.pwDialog != nil {
|
||||||
@@ -312,16 +559,38 @@ 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 st.settingsMenu != nil {
|
||||||
if act, closed := st.settingsMenu.Pick(gtx); closed {
|
if act, closed := st.settingsMenu.Pick(gtx); closed {
|
||||||
st.settingsMenu = nil
|
st.settingsMenu = nil
|
||||||
switch act {
|
switch act {
|
||||||
case settingsActionSetPassword:
|
case settingsActionSetPassword:
|
||||||
st.setPwDialog = newSetPasswordDialog()
|
st.setPwDialog = newSetPasswordDialog()
|
||||||
|
case settingsActionChangePassword:
|
||||||
|
st.changePwDlg = newChangePasswordDialog()
|
||||||
case settingsActionImportLegacy:
|
case settingsActionImportLegacy:
|
||||||
st.importDialog = newImportLegacyDialog()
|
st.importDialog = newImportLegacyDialog()
|
||||||
|
case settingsActionExport:
|
||||||
|
st.exportDlg = newExportDialog(st.snapshotEntries())
|
||||||
|
case settingsActionRestoreBackup:
|
||||||
|
st.restoreDlg = newRestoreBackupDialog()
|
||||||
|
case settingsActionPreferences:
|
||||||
|
lang, theme, autoLock, minToTray, autoStart, _, _ := st.store.Preferences()
|
||||||
|
st.prefsDialog = newPreferencesDialog(lang, normalizeThemeMode(theme), autoLock, minToTray, autoStart)
|
||||||
case settingsActionAbout:
|
case settingsActionAbout:
|
||||||
// TODO: about dialog (next phase).
|
st.aboutDialog = newAboutDialog()
|
||||||
}
|
}
|
||||||
w.Invalidate()
|
w.Invalidate()
|
||||||
} else {
|
} else {
|
||||||
@@ -329,6 +598,35 @@ 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()
|
||||||
|
_, _, _, _, _, winW, winH := st.store.Preferences()
|
||||||
|
st.store.SetPreferences(r.language, string(r.theme), r.autoLockMinutes, r.minimizeToTray, r.autoStart, winW, winH)
|
||||||
|
win32.SetAutoStart(r.autoStart)
|
||||||
|
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 {
|
if st.importDialog != nil {
|
||||||
return st.importDialog.Layout(gtx, th, func(r importLegacyResult) {
|
return st.importDialog.Layout(gtx, th, func(r importLegacyResult) {
|
||||||
if !r.cancel && r.cfg != nil {
|
if !r.cancel && r.cfg != nil {
|
||||||
@@ -340,6 +638,25 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if st.exportDlg != nil {
|
||||||
|
return st.exportDlg.Layout(gtx, th, func(r exportResult) {
|
||||||
|
st.exportDlg = nil
|
||||||
|
w.Invalidate()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.restoreDlg != nil {
|
||||||
|
return st.restoreDlg.Layout(gtx, th, func(r restoreBackupResult) {
|
||||||
|
if !r.cancel && r.cfg != nil {
|
||||||
|
st.mergeImportedConfig(r.cfg)
|
||||||
|
st.store.Push()
|
||||||
|
st.toast.Show(i18n.T("msg_restore_done"), w)
|
||||||
|
}
|
||||||
|
st.restoreDlg = nil
|
||||||
|
w.Invalidate()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if st.vendorMenu != nil {
|
if st.vendorMenu != nil {
|
||||||
if v, closed := st.vendorMenu.Pick(gtx); closed {
|
if v, closed := st.vendorMenu.Pick(gtx); closed {
|
||||||
st.vendorMenu = nil
|
st.vendorMenu = nil
|
||||||
@@ -372,8 +689,13 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
|
|||||||
if added != nil {
|
if added != nil {
|
||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
st.entries = append(st.entries, &entry{Name: name, Auth: added})
|
st.entries = append(st.entries, &entry{Name: name, Auth: added})
|
||||||
|
idx := len(st.entries) - 1
|
||||||
st.mu.Unlock()
|
st.mu.Unlock()
|
||||||
st.store.Push()
|
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
|
st.dialog = nil
|
||||||
w.Invalidate()
|
w.Invalidate()
|
||||||
@@ -396,6 +718,85 @@ 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)
|
||||||
|
case rowActionBnetInfo:
|
||||||
|
st.mu.Lock()
|
||||||
|
if idx >= 0 && idx < len(st.entries) {
|
||||||
|
if bn, ok := st.entries[idx].Auth.(*authenticator.BattleNetAuthenticator); ok {
|
||||||
|
st.bnetInfoDlg = newBnetInfoDialog(bn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.mu.Unlock()
|
||||||
|
}
|
||||||
|
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 {
|
if st.tradesDialog != nil {
|
||||||
return st.tradesDialog.Layout(gtx, th)
|
return st.tradesDialog.Layout(gtx, th)
|
||||||
}
|
}
|
||||||
@@ -427,7 +828,7 @@ func drawFrame(gtx layout.Context, th *material.Theme, st *appState, w *app.Wind
|
|||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
lbl := material.Body2(th, msg)
|
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)
|
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, lbl.Layout)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
@@ -485,9 +886,13 @@ func entryRow(gtx layout.Context, th *material.Theme, en *entry) layout.Dimensio
|
|||||||
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx,
|
return layout.Inset{Right: unit.Dp(8)}.Layout(gtx,
|
||||||
material.Button(th, &en.copyBtn, i18n.T("btn_copy")).Layout)
|
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 {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.H6(th, en.Code)
|
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)
|
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 |