diff --git a/kpmenulib/clientserver.go b/kpmenulib/clientserver.go index 3d0bad7..a397e3e 100644 --- a/kpmenulib/clientserver.go +++ b/kpmenulib/clientserver.go @@ -51,6 +51,7 @@ func StartServer(m *Menu) (err error) { // Handle packet request handlePacket := func(packet Packet) bool { log.Printf("received a client call with args \"%v\"", packet.CliArguments) + m.Configuration.Flags.Autotype = false m.CliArguments = packet.CliArguments return Show(m) } diff --git a/kpmenulib/clipboard.go b/kpmenulib/clipboard.go index 3003065..e35ef1d 100644 --- a/kpmenulib/clipboard.go +++ b/kpmenulib/clipboard.go @@ -7,7 +7,7 @@ import ( "os/exec" "strings" "time" - + "github.com/google/shlex" ) diff --git a/kpmenulib/config.go b/kpmenulib/config.go index 2d6eb90..e1a7ef1 100644 --- a/kpmenulib/config.go +++ b/kpmenulib/config.go @@ -28,17 +28,22 @@ type ConfigurationGeneral struct { CacheOneTime bool // Cache the password only the first time you write it CacheTimeout int // Timeout of cache NoOTP bool // Flag to do not handle OTPs + DisableAutotype bool // Disable autotype + AutotypeConfirm bool // User must always confirm + AutotypeNoAuto bool // Always prompt user to select the entry to autotype } // ConfigurationExecutable is the sub-structure of the configuration related to tools executed by kpmenu type ConfigurationExecutable struct { - CustomPromptPassword string // Custom executable for prompt password - CustomPromptMenu string // Custom executable for prompt menu - CustomPromptEntries string // Custom executable for prompt entries - CustomPromptFields string // Custom executable for prompt fields - CustomClipboardCopy string // Custom executable for clipboard copy - CustomClipboardPaste string // Custom executable for clipboard paste - CustomClipboardClean string // Custom executable for clipboard clean + CustomPromptPassword string // Custom executable for prompt password + CustomPromptMenu string // Custom executable for prompt menu + CustomPromptEntries string // Custom executable for prompt entries + CustomPromptFields string // Custom executable for prompt fields + CustomClipboardCopy string // Custom executable for clipboard copy + CustomClipboardPaste string // Custom executable for clipboard paste + CustomClipboardClean string // Custom executable for clipboard clean + CustomAutotypeWindowID string // Custom executable for fetching title of active window + CustomAutotypeTyper string // Custom executable for typing results } // ConfigurationStyle is the sub-structure of the configuration related to style of dmenu @@ -67,8 +72,9 @@ type ConfigurationDatabase struct { // Flags is the sub-structure of the configuration used to handle flags that aren't into the config file type Flags struct { - Daemon bool - Version bool + Daemon bool + Version bool + Autotype bool } // Menu tools used for prompts @@ -86,6 +92,12 @@ const ( ClipboardToolCustom = "custom" ) +// Autotype default helpers +const ( + AutotypeWindowIdentifier = "quasiauto -title" + AutotypeTyper = "quasiauto" +) + // NewConfiguration initializes a new Configuration pointer func NewConfiguration() *Configuration { return &Configuration{ @@ -107,6 +119,10 @@ func NewConfiguration() *Configuration { FieldOrder: "Password UserName URL", FillOtherFields: true, }, + Executable: ConfigurationExecutable{ + CustomAutotypeWindowID: AutotypeWindowIdentifier, + CustomAutotypeTyper: AutotypeTyper, + }, } } @@ -147,6 +163,7 @@ func (c *Configuration) InitializeFlags() { // Flags flag.BoolVar(&c.Flags.Daemon, "daemon", false, "Start kpmenu directly as daemon") flag.BoolVarP(&c.Flags.Version, "version", "v", false, "Show kpmenu version") + flag.BoolVar(&c.Flags.Autotype, "autotype", c.Flags.Autotype, "Initiate autotype") // General flag.StringVarP(&c.General.Menu, "menu", "m", c.General.Menu, "Choose which menu to use") @@ -156,6 +173,9 @@ func (c *Configuration) InitializeFlags() { flag.BoolVar(&c.General.CacheOneTime, "cacheOneTime", c.General.CacheOneTime, "Cache the database only the first time") flag.IntVar(&c.General.CacheTimeout, "cacheTimeout", c.General.CacheTimeout, "Timeout of cache in seconds") flag.BoolVar(&c.General.NoOTP, "nootp", c.General.NoOTP, "Disable OTP handling") + flag.BoolVar(&c.General.DisableAutotype, "noautotype", c.General.DisableAutotype, "Disable autotype handling") + flag.BoolVar(&c.General.AutotypeConfirm, "autotypealwaysconfirm", c.General.AutotypeConfirm, "Always confirm autotype, even when there's only 1 selection") + flag.BoolVar(&c.General.AutotypeNoAuto, "autotypeusersel", c.General.AutotypeNoAuto, "Prompt for autotype entry instead of trying to detect by active window title") // Executable flag.StringVar(&c.Executable.CustomPromptPassword, "customPromptPassword", c.Executable.CustomPromptPassword, "Custom executable for prompt password") @@ -164,6 +184,8 @@ func (c *Configuration) InitializeFlags() { flag.StringVar(&c.Executable.CustomPromptFields, "customPromptFields", c.Executable.CustomPromptFields, "Custom executable for prompt fields") flag.StringVar(&c.Executable.CustomClipboardCopy, "customClipboardCopy", c.Executable.CustomClipboardCopy, "Custom executable for clipboard copy") flag.StringVar(&c.Executable.CustomClipboardPaste, "customClipboardPaste", c.Executable.CustomClipboardPaste, "Custom executable for clipboard paste") + flag.StringVar(&c.Executable.CustomAutotypeWindowID, "customAutotypeWindowID", c.Executable.CustomAutotypeWindowID, "Custom executable for identifying active window for autotype") + flag.StringVar(&c.Executable.CustomAutotypeTyper, "customAutotypeTyper", c.Executable.CustomAutotypeTyper, "Custom executable for autotype typer") flag.StringVar(&c.Executable.CustomClipboardClean, "customClipboardClean", c.Executable.CustomClipboardClean, "Custom executable for clipboard clean") // Style diff --git a/kpmenulib/kpmenulib.go b/kpmenulib/kpmenulib.go index 42c67f7..debbfd7 100644 --- a/kpmenulib/kpmenulib.go +++ b/kpmenulib/kpmenulib.go @@ -46,6 +46,13 @@ func Execute(menu *Menu) bool { } } + if !menu.Configuration.General.DisableAutotype && menu.Configuration.Flags.Autotype { + if err := PromptAutotype(menu); err.Error != nil { + log.Print(err.Error) + } + return false + } + // Open menu if err := menu.OpenMenu(); err != nil { log.Print(err) diff --git a/kpmenulib/otp.go b/kpmenulib/otp.go index 557e70b..240b02f 100644 --- a/kpmenulib/otp.go +++ b/kpmenulib/otp.go @@ -50,15 +50,14 @@ func (o OTPError) Error() string { // Modern versions of KeepassXC and Keepass2Android store this URL in the `otp` key. A historic version // stored data ds: // -// TOTP Seed = SECRET -// TOTP Settings = PERIOD;DIGITS +// TOTP Seed = SECRET +// TOTP Settings = PERIOD;DIGITS // // If the `otp` key exists, it should be used and the TOTP values ignored; otherwise, the legacy values can // be used. // // entry is the DB entry for which to generate a code; time is the Unix time to generate for the code -- // generally time.Now().Unix() -// func CreateOTP(a gokeepasslib.Entry, time int64) (otp string, err error) { otpa, err := CreateOTPAuth(a) if err != nil { @@ -91,6 +90,9 @@ func (o OTPAuth) Create(time int64) (otp string, err error) { otp = fmt.Sprint(r % int32(pow(10, o.Digits))) if len(otp) != o.Digits { + if len(otp) > o.Digits { + return otp, fmt.Errorf("otp length (%d) must be greater than Digits (%d)", len(otp), o.Digits) + } rpt := strings.Repeat("0", o.Digits-len(otp)) otp = rpt + otp } @@ -165,11 +167,11 @@ func CreateOTPAuth(a gokeepasslib.Entry) (otp OTPAuth, err error) { // parseOTPAuth parses a Google Authenticator otpauth URL, which is used by // both KeepassXC and Keepass2Android. // -// otpauth://TYPE/LABEL?PARAMETERS +// otpauth://TYPE/LABEL?PARAMETERS // // e.g., the KeepassXC format is // -// otpauth://totp/ISSUER:USERNAME?secret=SECRET&period=SECONDS&digits=D&issuer=ISSUER +// otpauth://totp/ISSUER:USERNAME?secret=SECRET&period=SECONDS&digits=D&issuer=ISSUER // // where TITLE is the record entry title, e.g. `github`; USERNAME is the entry // user name, e.g. `xxxserxxx`; SECRET is the TOTP seed secret; SECONDS is the @@ -179,7 +181,10 @@ func CreateOTPAuth(a gokeepasslib.Entry) (otp OTPAuth, err error) { // // The spec is at https://github.com/google/google-authenticator/wiki/Key-Uri-Format func parseOTPAuth(s string) (OTPAuth, error) { - otp := OTPAuth{} + otp := OTPAuth{ + Digits: 6, + Period: 30, + } u, err := url.ParseRequestURI(s) if err != nil { return otp, err diff --git a/kpmenulib/prompt.go b/kpmenulib/prompt.go index c5441d5..5f50cc3 100644 --- a/kpmenulib/prompt.go +++ b/kpmenulib/prompt.go @@ -9,6 +9,7 @@ import ( "time" "github.com/google/shlex" + "github.com/tobischo/gokeepasslib/v3" ) // MenuSelection is an enum used for prompt menu selection @@ -42,40 +43,10 @@ type ErrorPrompt struct { // Returns the written password func PromptPassword(menu *Menu) (string, ErrorPrompt) { // Prepare dmenu/rofi - var command []string - switch menu.Configuration.General.Menu { - case "rofi": - command = []string{ - "rofi", - "-i", - "-dmenu", - "-p", menu.Configuration.Style.TextPassword, - "-password", - } - case "wofi": - command = []string{ - "wofi", - "-i", - "-d", - "-p", menu.Configuration.Style.TextPassword, - "--password", - } - case "dmenu": - command = []string{ - "dmenu", - "-i", - "-p", menu.Configuration.Style.TextPassword, - "-nb", menu.Configuration.Style.PasswordBackground, - "-nf", menu.Configuration.Style.PasswordBackground, - } - case "custom": - var err error - command, err = shlex.Split(menu.Configuration.Executable.CustomPromptPassword) - if err != nil { - var errorPrompt ErrorPrompt - errorPrompt.Error = fmt.Errorf("failed to parse custom prompt password, exiting") - return "", errorPrompt - } + command, err := getCommand(menu, menu.Configuration.Style.TextPassword, true, menu.Configuration.Executable.CustomPromptPassword) + ep := ErrorPrompt{} + if err != ep { + return "", err } // Add custom arguments @@ -94,37 +65,10 @@ func PromptMenu(menu *Menu) (MenuSelection, ErrorPrompt) { var input strings.Builder // Prepare dmenu/rofi - var command []string - switch menu.Configuration.General.Menu { - case "rofi": - command = []string{ - "rofi", - "-i", - "-dmenu", - "-p", menu.Configuration.Style.TextMenu, - } - case "wofi": - command = []string{ - "wofi", - "-i", - "-d", - "-p", menu.Configuration.Style.TextMenu, - } - case "dmenu": - command = []string{ - "dmenu", - "-i", - "-p", menu.Configuration.Style.TextMenu, - } - case "custom": - var err error - command, err = shlex.Split(menu.Configuration.Executable.CustomPromptMenu) - if err != nil { - var errorPrompt ErrorPrompt - errorPrompt.Cancelled = true - errorPrompt.Error = fmt.Errorf("failed to parse custom prompt menu, exiting") - return 0, errorPrompt - } + command, err := getCommand(menu, menu.Configuration.Style.TextMenu, false, menu.Configuration.Executable.CustomPromptMenu) + ep := ErrorPrompt{} + if err != ep { + return selection, err } // Add custom arguments @@ -158,37 +102,11 @@ func PromptEntries(menu *Menu) (*Entry, ErrorPrompt) { var entry Entry var input strings.Builder - // Prepare dmenu/rofi - var command []string - switch menu.Configuration.General.Menu { - case "rofi": - command = []string{ - "rofi", - "-i", - "-dmenu", - "-p", menu.Configuration.Style.TextEntry, - } - case "wofi": - command = []string{ - "wofi", - "-i", - "-d", - "-p", menu.Configuration.Style.TextEntry, - } - case "dmenu": - command = []string{ - "dmenu", - "-i", - "-p", menu.Configuration.Style.TextEntry, - } - case "custom": - var err error - command, err = shlex.Split(menu.Configuration.Executable.CustomPromptEntries) - if err != nil { - var errorPrompt ErrorPrompt - errorPrompt.Error = fmt.Errorf("failed to parse custom prompt entries, exiting") - return nil, errorPrompt - } + // Prepare autotype command + command, erp := getCommand(menu, menu.Configuration.Style.TextEntry, false, menu.Configuration.Executable.CustomPromptEntries) + ep := ErrorPrompt{} + if erp != ep { + return &entry, erp } // Add custom arguments @@ -250,37 +168,11 @@ func PromptFields(menu *Menu, entry *Entry) (string, ErrorPrompt) { var value string var input strings.Builder - // Prepare dmenu/rofi - var command []string - switch menu.Configuration.General.Menu { - case "rofi": - command = []string{ - "rofi", - "-i", - "-dmenu", - "-p", menu.Configuration.Style.TextEntry, - } - case "wofi": - command = []string{ - "wofi", - "-i", - "-d", - "-p", menu.Configuration.Style.TextEntry, - } - case "dmenu": - command = []string{ - "dmenu", - "-i", - "-p", menu.Configuration.Style.TextField, - } - case "custom": - var err error - command, err = shlex.Split(menu.Configuration.Executable.CustomPromptFields) - if err != nil { - var errorPrompt ErrorPrompt - errorPrompt.Error = fmt.Errorf("failed to parse custom prompt fields, exiting") - return "", errorPrompt - } + // Prepare autotype command + command, erp := getCommand(menu, menu.Configuration.Style.TextEntry, false, menu.Configuration.Executable.CustomPromptFields) + ep := ErrorPrompt{} + if erp != ep { + return value, erp } // Add custom arguments @@ -352,6 +244,144 @@ func PromptFields(menu *Menu, entry *Entry) (string, ErrorPrompt) { return value, err } +func PromptChoose(menu *Menu, items []string) (int, ErrorPrompt) { + var input strings.Builder + + // Prepare autotype command + command, erp := getCommand(menu, menu.Configuration.Style.TextEntry, false, menu.Configuration.Executable.CustomPromptFields) + ep := ErrorPrompt{} + if erp != ep { + return -1, erp + } + + // Prepare input (dmenu items) + for _, e := range items { + input.WriteString(e + "\n") + } + + // Execute prompt + result, err := executePrompt(command, strings.NewReader(input.String())) + if err.Error == nil && !err.Cancelled { + // Ensures selection is one of the items + for i, sel := range items { + // Match for entry title and selected entry + if result == sel { + return i, err + } + } + } + return -1, err +} + +// PromptAutotype executes an external application to select an entry and then +// runs an autotype program with the entry's data. +// +// Field data is sent to the autotype child process on STDIN as TSV data. The +// first row is the key sequence. +// +// {USERNAME}{TAB}{PASSWORD}{ENTER} +// key value +// +// If Keepass attributes for autotype exist for the record, they're used. If +// they do not exist, username & password are used; if there is no default key +// sequence, `{username}{tab}{password}{tab}{totp}{enter}` is used if OTP is +// set and OTP is not disabled, or `{USERNAME}{TAB}{PASSWORD}{ENTER}` +// otherwise. The key sequence is parsed, and only the key/values defined in +// the sequence are sent. It is incumbent on the autotype callee to parse the +// sequence for meta entries such as DELAY and special characters. +// +// STDIN is closed when all fields have been sent. +// +// [Keepass match rules](https://keepass.info/help/base/autotype.html) are, in +// order of precedence: +// 1. Association string as a regexp (if the string is wrapped in `//`) +// 2. Association string as a simple glob +// 3. Entry title as a subset of the window title +// +// If multiple matches are found, the user is prompted to select one. +// +// If `--autotypenoauto` is set, the user will *always* be prompted run autotype, or cancel. +// +// `--noautotype` is handled by the caller -- this function does not perform that check. +func PromptAutotype(menu *Menu) ErrorPrompt { + var entry *Entry + // The rule for keepass(es) key sequence selection is: + // Assoc > Configured entry default > appl. default + var keySeq string + var errPrompt ErrorPrompt + if menu.Configuration.General.AutotypeNoAuto { + entry, errPrompt = PromptEntries(menu) + if entry == nil || errPrompt.Cancelled { + errPrompt.Cancelled = true + errPrompt.Error = fmt.Errorf("user cancelled") + return errPrompt + } + + // Try to guess the key sequence + keySeq = entry.FullEntry.AutoType.DefaultSequence + if keySeq == "" { + if entry.FullEntry.AutoType.Associations != nil { + for _, assoc := range entry.FullEntry.AutoType.Associations { + if assoc.KeystrokeSequence != "" { + keySeq = assoc.KeystrokeSequence + break + } + } + } + } + } else { + entry, keySeq, errPrompt = identifyWindow(menu) + if entry == nil || errPrompt.Cancelled { + errPrompt.Cancelled = true + errPrompt.Error = fmt.Errorf("no entry matched") + return errPrompt + } + } + + if keySeq == "" { + keySeq = "{USERNAME}{TAB}{PASSWORD}{ENTER}" + } + + fe := entry.FullEntry + var input strings.Builder + input.WriteString(keySeq) + input.WriteString("\n") + seq := NewSequence() + seq.Parse(keySeq) + for _, k := range seq.SeqEntries { + if k.Type == FIELD { + var value string + if k.Token == "TOTP" { + // If the sequence asks for TOTP but the user has disabled it + // write a dummy code. **Not** writing it would break the + // sequence, which is ordered. + if menu.Configuration.General.NoOTP { + value = "000000" + } else { + var err error + value, err = CreateOTP(fe, time.Now().Unix()) + if err != nil { + errPrompt.Cancelled = true + errPrompt.Error = fmt.Errorf("failed to create otp: %s", err) + return errPrompt + } + } + } else { + value = getContent(fe, k.Token) + } + input.WriteString(k.Token) + input.WriteString("\t") + input.WriteString(value) + input.WriteString("\n") + } + } + + command := strings.Split(menu.Configuration.Executable.CustomAutotypeTyper, " ") + _, errPrompt = executePrompt(command, strings.NewReader(input.String())) + + return errPrompt +} + func executePrompt(command []string, input *strings.Reader) (result string, errorPrompt ErrorPrompt) { var out bytes.Buffer var outErr bytes.Buffer @@ -392,15 +422,160 @@ func executePrompt(command []string, input *strings.Reader) (result string, erro return } -func contains(array []string, value string) bool { - for _, n := range array { - if value == n { - return true +func (el MenuSelection) String() string { + return menuSelections[el] +} + +func getCommand(menu *Menu, style string, pass bool, custom string) ([]string, ErrorPrompt) { + var command []string + switch menu.Configuration.General.Menu { + case "rofi": + command = []string{ + "rofi", + "-i", + "-dmenu", + "-p", style, + } + if pass { + command = append(command, "-password") + } + case "wofi": + command = []string{ + "wofi", + "-i", + "-d", + "-p", style, + } + if pass { + command = append(command, "--password") + } + case "dmenu": + command = []string{ + "dmenu", + "-i", + "-p", style, + } + if pass { + command = append(command, []string{ + "-nb", menu.Configuration.Style.PasswordBackground, + "-nf", menu.Configuration.Style.PasswordBackground, + }...) + } + case "custom": + var err error + command, err = shlex.Split(custom) + if err != nil { + var errorPrompt ErrorPrompt + errorPrompt.Cancelled = true + errorPrompt.Error = fmt.Errorf("failed to parse custom prompt, exiting") + return []string{}, errorPrompt } } - return false + return command, ErrorPrompt{} } -func (el MenuSelection) String() string { - return menuSelections[el] +func identifyWindow(menu *Menu) (*Entry, string, ErrorPrompt) { + // Prepare autotype command + command := strings.Split(menu.Configuration.Executable.CustomAutotypeWindowID, " ") + + activeWindow, errPrompt := executePrompt(command, nil) + if errPrompt.Error != nil || errPrompt.Cancelled { + return &Entry{}, "", errPrompt + } + + type pair struct { + reg string + ent Entry + seq string + } + matches := make([]pair, 0) + for _, e := range menu.Database.Entries { + defaultSequence := "{USERNAME}{TAB}{PASSWORD}{ENTER}" + if e.FullEntry.AutoType.DefaultSequence != "" { + defaultSequence = e.FullEntry.AutoType.DefaultSequence + } + // [Regexp, KeySequence] + mss := [][]string{{".*" + e.FullEntry.GetContent("Title") + ".*", defaultSequence}} + if e.FullEntry.AutoType.Associations != nil { + for _, at := range e.FullEntry.AutoType.Associations { + // Check if there's a window association, and if so, make sure it's a regexp + if at.Window == "" { + continue + } + ms := at.Window + if !strings.HasPrefix(at.Window, "//") { + // Replace all star globs with .* + ms = strings.ReplaceAll(ms, "*", ".*") + } else { + // For regexp, remove the wrapping + if len(ms) > 2 { + ms = ms[2:] + } + if len(ms) > 2 { + ms = ms[:len(ms)-2] + } + } + seq := defaultSequence + if at.KeystrokeSequence != "" { + seq = at.KeystrokeSequence + } + mss = append(mss, []string{ms, seq}) + } + } + for _, ms := range mss { + reg, err := regexp.Compile(ms[0]) + if err != nil { + continue + } + if reg.Match([]byte(activeWindow)) { + matches = append(matches, pair{ms[0], e, ms[1]}) + } + } + } + + var entry *Entry + var keySeq string + switch len(matches) { + case 0: + errPrompt.Error = fmt.Errorf("no autotype window match for %s", activeWindow) + return entry, keySeq, errPrompt + case 1: + entry = &matches[0].ent + keySeq = matches[0].seq + default: + items := make([]string, len(matches)) + for i, m := range matches { + items[i] = fmt.Sprintf("%s - %s - (%s)", m.ent.FullEntry.GetContent("Title"), m.reg, m.seq) + } + sel, err := PromptChoose(menu, items) + ep := ErrorPrompt{} + if err != ep || sel == -1 { + return entry, keySeq, ep + } + entry = &matches[sel].ent + keySeq = matches[sel].seq + } + + if len(matches) == 1 && menu.Configuration.General.AutotypeConfirm { + sel, err := PromptChoose(menu, []string{ + "Auto-type " + entry.FullEntry.GetContent("Title"), + "Cancel", + }) + nulErr := ErrorPrompt{} + if sel != 0 || err != nulErr { + errPrompt.Cancelled = true + return entry, "", errPrompt + } + } + return entry, keySeq, errPrompt +} + +func getContent(e gokeepasslib.Entry, k string) string { + k = strings.ToLower(k) + for _, v := range e.Values { + if strings.ToLower(v.Key) == k { + return v.Value.Content + } + } + return "" } diff --git a/kpmenulib/sequence.go b/kpmenulib/sequence.go new file mode 100644 index 0000000..8a6a5bf --- /dev/null +++ b/kpmenulib/sequence.go @@ -0,0 +1,218 @@ +package kpmenulib + +import ( + "fmt" + "regexp" + "strings" +) + +const ( + // Field names, e.g. {USERNAME} + FIELD = iota + // Keywords, e.g. {TAB} or {ENTER} + KEYWORD + // Commands, e.g. {DELAY 5} + COMMAND + // Raw text, e.g. text not enclosed in {} + RAW + // Special alias characters, e.g. a ^ not in {} means "control" + SPECIAL +) + +// SeqEntry is a single token in a key sequence, denoted by the token, the +// parsed type, and any args if it is a command. +type SeqEntry struct { + // token is the processed text, stripped of {} + Token string + // args is only set for COMMANDs, and will be nil otherwise + Args []string + // The type of the sequence entry, e.g. KEYWORD, COMMAND, etc. + Type int +} + +type SeqEntries []SeqEntry + +// Sequence is a parsed sequence of tokens +type Sequence struct { + SeqEntries + Keylag int +} + +// NewSequence returns a new Sequence instance bound to a typer. Unless mocking, this +// should normally be: +// ``` +// s := NewSequence(Robot{}) +// ``` +func NewSequence() Sequence { + return Sequence{ + make(SeqEntries, 0), + 50, + } +} + +// Parse processes a [Keepass autotype sequence](https://keepass.info/help/base/autotype.html) +// and returns the parsed keys in the order in which they occurred. +func (rv *Sequence) Parse(keySeq string) error { + if _atKeySeqRE == nil { + initKeySeqParser() + } + if len(keySeq) == 0 { + return fmt.Errorf("received empty sequence") + } + matches := _atKeySeqRE.FindAllString(keySeq, -1) + if len(matches) == 0 { + return fmt.Errorf("received malformed sequence %#v", keySeq) + } + for _, match := range matches { + var s SeqEntry + switch { + case match == "{{}": + s.Token = "{" + s.Type = KEYWORD + case match == "{}}": + s.Token = "}" + s.Type = KEYWORD + case match[0] == '{': + match = strings.Trim(match, "{}") + match = strings.Trim(match, " ") + match = strings.Replace(match, "=", " ", 1) + parts := strings.Split(match, " ") + s.Token = parts[0] + if contains(_commands, s.Token) { + s.Type = COMMAND + if len(parts) > 1 { + s.Args = parts[1:] + } else { + s.Args = []string{} + } + } else if contains(_atKeywords, match) { + s.Token = match + s.Type = KEYWORD + } else if len(match) == 0 { + return fmt.Errorf("invalid key sequence {}") + } else { + s.Token = match + s.Type = FIELD + } + default: + s.Token = match + s.Type = RAW + if contains(_specials, match) { + s.Type = SPECIAL + } + } + rv.SeqEntries = append(rv.SeqEntries, s) + } + return nil +} + +const ( + // Argument-laden keywords: DELAY, VKEY, APPACTIVATE, BEEP + AT_KW_DELAY = "DELAY" + AT_KW_VKEY = "VKEY" + AT_KW_APPACT = "APPACTIVATE" + AT_KW_BEEP = "BEEP" + // No-argument keywords + AT_KW_CLEAR = "CLEARFIELD" + AT_KW_PLUS = "PLUS" + AT_KW_PERCENT = "PERCENT" + AT_KW_CARET = "CARET" + AT_KW_TILDE = "TILDE" + AT_KW_LEFTPAREN = "LEFTPAREN" + AT_KW_RIGHTPAREN = "RIGHTPAREN" + AT_KW_LEFTBRACE = "LEFTBRACE" + AT_KW_RIGHTBRACE = "RIGHTBRACE" + AT_KW_AT = "AT" + AT_KW_TAB = "TAB" + AT_KW_ENTER = "ENTER" + AT_KW_ARROW_UP = "UP" + AT_KW_ARROW_DOWN = "DOWN" + AT_KW_ARROW_LEFT = "LEFT" + AT_KW_ARROW_RIGHT = "RIGHT" + AT_KW_INSERT = "INSERT" + AT_KW_INSERT2 = "INS" + AT_KW_DELETE = "DELETE" + AT_KW_DELETE2 = "DEL" + AT_KW_HOME = "HOME" + AT_KW_END = "END" + AT_KW_PAGE_UP = "PGUP" + AT_KW_PAGE_DOWN = "PGDN" + AT_KW_SPACE = "SPACE" + AT_KW_BREAK = "BREAK" + AT_KW_CAPS_LOCK = "CAPSLOCK" + AT_KW_ESCAPE = "ESC" + AT_KW_BACKSPACE = "BACKSPACE" + AT_KW_KW_BACKSPACE2 = "BS" + AT_KW_KW_BACKSPACE3 = "BKSP" + AT_KW_WINDOWS_KEY_LEFT = "WIN" + AT_KW_WINDOWS_KEY_LEFT2 = "LWIN" + AT_KW_WINDOWS_KEY_RIGHT = "RWIN" + AT_KW_HELP = "HELP" + AT_KW_NUMLOCK = "NUMLOCK" + AT_KW_PRINTSCREEN = "PRTSC" + AT_KW_SCROLLLOCK = "SCROLLLOCK" + AT_KW_NUMPAD_PLUS = "ADD" + AT_KW_NUMPAD_MINUS = "SUBTRACT" + AT_KW_NUMPAD_MULT = "MULTIPLY" + AT_KW_NUMPAD_DIV = "DIVIDE" + AT_KW_APPS_MENU = "APPS" + //Numeric pad 0 to 9 {NUMPAD0} to {NUMPAD9} + // F1 - F16 {F1} - {F16} + // Special characters within {} -- thanks, keepass2! + AT_SCHAR = "+%^~{}[]()" + // Special characters *outside* of {} + AT_CH_SHIFT = "+" + AT_CH_CTRL = "^" + AT_CH_ALT = "%" + AT_CH_WINDOWSKEY = "@" + AT_CH_ENTER2 = "~" +) + +var _commands []string = []string{AT_KW_DELAY, AT_KW_VKEY, AT_KW_APPACT, AT_KW_BEEP} + +var _specials []string = []string{AT_CH_SHIFT, AT_CH_CTRL, AT_CH_ALT, AT_CH_WINDOWSKEY, AT_CH_ENTER2} + +// This has been benchmarked. An O(M*N) array search is faster than either map or regex. +var _atKeywords []string = []string{ + AT_KW_CLEAR, AT_KW_PLUS, AT_KW_PERCENT, + AT_KW_CARET, AT_KW_TILDE, AT_KW_LEFTPAREN, AT_KW_RIGHTPAREN, AT_KW_LEFTBRACE, AT_KW_RIGHTBRACE, + AT_KW_AT, AT_KW_TAB, AT_KW_ENTER, AT_KW_ARROW_UP, AT_KW_ARROW_DOWN, AT_KW_ARROW_LEFT, + AT_KW_ARROW_RIGHT, AT_KW_INSERT, AT_KW_INSERT2, AT_KW_DELETE, AT_KW_DELETE2, AT_KW_HOME, + AT_KW_END, AT_KW_PAGE_UP, AT_KW_PAGE_DOWN, AT_KW_SPACE, AT_KW_BREAK, AT_KW_CAPS_LOCK, + AT_KW_ESCAPE, AT_KW_BACKSPACE, AT_KW_KW_BACKSPACE2, AT_KW_KW_BACKSPACE3, AT_KW_WINDOWS_KEY_LEFT, + AT_KW_WINDOWS_KEY_LEFT2, AT_KW_WINDOWS_KEY_RIGHT, AT_KW_HELP, AT_KW_NUMLOCK, AT_KW_PRINTSCREEN, + AT_KW_SCROLLLOCK, AT_KW_NUMPAD_PLUS, AT_KW_NUMPAD_MINUS, AT_KW_NUMPAD_MULT, AT_KW_NUMPAD_DIV, + AT_KW_APPS_MENU, +} + +// Breaks a sequence string into tokens +var _atKeySeqRE *regexp.Regexp + +// Don't make this init() -- it will (unnecessarily) slow down client calls. You probably +// don't want to call it; it's called by `Parse()` if necessary +func initKeySeqParser() { + _atKeySeqRE = regexp.MustCompile("\\{[^\\}]+\\}|[^\\{\\}]+|\\s+|\\{\\{\\}|\\{\\}\\}") + + // Add special characters in {} + for _, c := range strings.Split(AT_SCHAR, "") { + _atKeywords = append(_atKeywords, c) + } + // NUMPAD0 - NUMPAD9 + for i := 0; i < 10; i++ { + _atKeywords = append(_atKeywords, fmt.Sprintf("NUMPAD%d", i)) + } + // F1 - F16 + for i := 1; i < 17; i++ { + _atKeywords = append(_atKeywords, fmt.Sprintf("F%d", i)) + } +} + +// contains answers: does an array of strings contain a string? +func contains(kws []string, s string) bool { + for _, keyword := range kws { + if keyword == s { + return true + } + } + return false +} diff --git a/kpmenulib/sequence_test.go b/kpmenulib/sequence_test.go new file mode 100644 index 0000000..8503bdb --- /dev/null +++ b/kpmenulib/sequence_test.go @@ -0,0 +1,108 @@ +package kpmenulib + +import ( + "testing" +) + +func TestNewSequence(t *testing.T) { + s := NewSequence() + s.SeqEntries = append(s.SeqEntries, SeqEntry{}) + if 1 != len(s.SeqEntries) { + t.Fatalf("expected %d, got %d", 1, len(s.SeqEntries)) + } +} + +func Test_parseKeySeq(t *testing.T) { + tests := []struct { + name string + input string + expected int + }{ + {"Parse", "{PLUS}{TAB}{F7}heyho{NUMPAD5}{DELAY 5}{VKEY 5 6}{APPACTIVATE window}{BEEP 100 200}{[}", 10}, + {"Token count", "{PLUS}{TAB}{F7}heyho{NUMPAD5}{TILDE}{ENTER}{[}{%}", 9}, + {"One token", "{PLUS}", 1}, + {"Empty", "", 0}, + {"Only a field", "{TOTP}", 1}, + {"Only a command", "{DELAY 5}", 1}, + {"Only text", "TOTP", 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + seq := NewSequence() + seq.Parse(tt.input) + if len(seq.SeqEntries) != tt.expected { + t.Errorf("parseKeySeq() %s = %v, want %v", tt.name, seq.SeqEntries, tt.expected) + } + }) + } +} + +func Test_parseKeySeqTokens(t *testing.T) { + tests := []struct { + name string + input string + expected SeqEntry + }{ + {"Keyword", "{TAB}", SeqEntry{Token: "TAB", Args: nil, Type: KEYWORD}}, + {"Keyword - left brace", "{{}", SeqEntry{Token: "{", Args: nil, Type: KEYWORD}}, + {"Keyword - right brace", "{}}", SeqEntry{Token: "}", Args: nil, Type: KEYWORD}}, + {"F-key", "{F11}", SeqEntry{Token: "F11", Args: nil, Type: KEYWORD}}, + {"Numpad", "{NUMPAD0}", SeqEntry{Token: "NUMPAD0", Args: nil, Type: KEYWORD}}, + {"Raw", "raw text", SeqEntry{Token: "raw text", Args: nil, Type: RAW}}, + {"Command", "{BEEP 300 123}", SeqEntry{Token: "BEEP", Args: []string{"300", "123"}, Type: COMMAND}}, + {"Character", "{~}", SeqEntry{Token: "~", Args: nil, Type: KEYWORD}}, + {"Command", "{BEEP 300 123}", SeqEntry{Token: "BEEP", Args: []string{"300", "123"}, Type: COMMAND}}, + {"Special char", "@", SeqEntry{Token: "@", Args: nil, Type: SPECIAL}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + seq := NewSequence() + seq.Parse(tt.input) + if 1 != len(seq.SeqEntries) { + t.Fatalf("expected %d, got %d", 1, len(seq.SeqEntries)) + } + if len(seq.SeqEntries) == 1 { + e := seq.SeqEntries[0] + if tt.expected.Token != e.Token { + t.Fatalf("expected %#v, got %#v", tt.expected.Token, e.Token) + } + if len(tt.expected.Args) != len(e.Args) { + t.Fatalf("expected %#v, got %#v", tt.expected.Args, e.Args) + } else { + for i, el := range tt.expected.Args { + if e.Args[i] != el { + t.Fatalf("expected %#v, got %#v", e, e.Args[i]) + } + } + } + if tt.expected.Type != e.Type { + t.Fatalf("expected %d, got %d", tt.expected.Type, e.Type) + } + } + }) + } +} + +func BenchmarkMap(b *testing.B) { + input := "{BEEP}{PLUS}{USERNAME}{TAB}{F7}heyho{NUMPAD5}{TILDE}{PASSWORD}{ENTER}{[}{%}" + for i := 0; i < b.N; i++ { + seq := NewSequence() + seq.Parse(input) + } +} + +func Test_initKeySeqParser(t *testing.T) { + tests := []string{ + "TAB", "NUMPAD3", "F10", "}", "%", + } + initKeySeqParser() +outer: + for _, i := range tests { + for _, e := range _atKeywords { + if i == e { + continue outer + } + } + t.Errorf("expected to find %s", i) + } +}