diff options
Diffstat (limited to 'app/aerc.go')
-rw-r--r-- | app/aerc.go | 908 |
1 files changed, 908 insertions, 0 deletions
diff --git a/app/aerc.go b/app/aerc.go new file mode 100644 index 00000000..a1995c2d --- /dev/null +++ b/app/aerc.go @@ -0,0 +1,908 @@ +package app + +import ( + "errors" + "fmt" + "io" + "net/url" + "os/exec" + "sort" + "strings" + "time" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/emersion/go-message/mail" + "github.com/gdamore/tcell/v2" + "github.com/google/shlex" + + "git.sr.ht/~rjarry/aerc/config" + "git.sr.ht/~rjarry/aerc/lib" + "git.sr.ht/~rjarry/aerc/lib/crypto" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/log" + "git.sr.ht/~rjarry/aerc/models" + "git.sr.ht/~rjarry/aerc/worker/types" +) + +type Aerc struct { + accounts map[string]*AccountView + cmd func([]string, *config.AccountConfig, *models.MessageInfo) error + cmdHistory lib.History + complete func(cmd string) ([]string, string) + focused ui.Interactive + grid *ui.Grid + simulating int + statusbar *ui.Stack + statusline *StatusLine + pasting bool + pendingKeys []config.KeyStroke + prompts *ui.Stack + tabs *ui.Tabs + ui *ui.UI + beep func() error + dialog ui.DrawableInteractive + + Crypto crypto.Provider +} + +type Choice struct { + Key string + Text string + Command []string +} + +func NewAerc( + crypto crypto.Provider, + cmd func([]string, *config.AccountConfig, *models.MessageInfo) error, + complete func(cmd string) ([]string, string), cmdHistory lib.History, + deferLoop chan struct{}, +) *Aerc { + tabs := ui.NewTabs(config.Ui) + + statusbar := ui.NewStack(config.Ui) + statusline := &StatusLine{} + statusbar.Push(statusline) + + grid := ui.NewGrid().Rows([]ui.GridSpec{ + {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, + }).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + grid.AddChild(tabs.TabStrip) + grid.AddChild(tabs.TabContent).At(1, 0) + grid.AddChild(statusbar).At(2, 0) + + aerc := &Aerc{ + accounts: make(map[string]*AccountView), + cmd: cmd, + cmdHistory: cmdHistory, + complete: complete, + grid: grid, + statusbar: statusbar, + statusline: statusline, + prompts: ui.NewStack(config.Ui), + tabs: tabs, + Crypto: crypto, + } + + statusline.SetAerc(aerc) + + for _, acct := range config.Accounts { + view, err := NewAccountView(aerc, acct, aerc, deferLoop) + if err != nil { + tabs.Add(errorScreen(err.Error()), acct.Name, nil) + } else { + aerc.accounts[acct.Name] = view + view.tab = tabs.Add(view, acct.Name, view.UiConfig()) + } + } + + if len(config.Accounts) == 0 { + wizard := NewAccountWizard(aerc) + wizard.Focus(true) + aerc.NewTab(wizard, "New account") + } + + tabs.Select(0) + + tabs.CloseTab = func(index int) { + tab := aerc.tabs.Get(index) + if tab == nil { + return + } + switch content := tab.Content.(type) { + case *AccountView: + return + case *AccountWizard: + return + default: + aerc.RemoveTab(content, true) + } + } + + aerc.showConfigWarnings() + + return aerc +} + +func (aerc *Aerc) showConfigWarnings() { + var dialogs []ui.DrawableInteractive + + callback := func(string, error) { + aerc.CloseDialog() + if len(dialogs) > 0 { + d := dialogs[0] + dialogs = dialogs[1:] + aerc.AddDialog(d) + } + } + + for _, w := range config.Warnings { + dialogs = append(dialogs, NewSelectorDialog( + w.Title, w.Body, []string{"OK"}, 0, + aerc.SelectedAccountUiConfig(), + callback, + )) + } + + callback("", nil) +} + +func (aerc *Aerc) OnBeep(f func() error) { + aerc.beep = f +} + +func (aerc *Aerc) Beep() { + if aerc.beep == nil { + log.Warnf("should beep, but no beeper") + return + } + if err := aerc.beep(); err != nil { + log.Errorf("tried to beep, but could not: %v", err) + } +} + +func (aerc *Aerc) HandleMessage(msg types.WorkerMessage) { + if acct, ok := aerc.accounts[msg.Account()]; ok { + acct.onMessage(msg) + } +} + +func (aerc *Aerc) Invalidate() { + ui.Invalidate() +} + +func (aerc *Aerc) Focus(focus bool) { + // who cares +} + +func (aerc *Aerc) Draw(ctx *ui.Context) { + if len(aerc.prompts.Children()) > 0 { + previous := aerc.focused + prompt := aerc.prompts.Pop().(*ExLine) + prompt.finish = func() { + aerc.statusbar.Pop() + aerc.focus(previous) + } + + aerc.statusbar.Push(prompt) + aerc.focus(prompt) + } + aerc.grid.Draw(ctx) + if aerc.dialog != nil { + if w, h := ctx.Width(), ctx.Height(); w > 8 && h > 4 { + if d, ok := aerc.dialog.(Dialog); ok { + start, height := d.ContextHeight() + aerc.dialog.Draw( + ctx.Subcontext(4, start(h), + w-8, height(h))) + } else { + aerc.dialog.Draw(ctx.Subcontext(4, h/2-2, w-8, 4)) + } + } + } +} + +func (aerc *Aerc) HumanReadableBindings() []string { + var result []string + binds := aerc.getBindings() + format := func(s string) string { + return strings.ReplaceAll(s, "%", "%%") + } + fmtStr := "%10s %s" + for _, bind := range binds.Bindings { + result = append(result, fmt.Sprintf(fmtStr, + format(config.FormatKeyStrokes(bind.Input)), + format(config.FormatKeyStrokes(bind.Output)), + )) + } + if binds.Globals && config.Binds.Global != nil { + for _, bind := range config.Binds.Global.Bindings { + result = append(result, fmt.Sprintf(fmtStr+" (Globals)", + format(config.FormatKeyStrokes(bind.Input)), + format(config.FormatKeyStrokes(bind.Output)), + )) + } + } + result = append(result, fmt.Sprintf(fmtStr, + "$ex", + fmt.Sprintf("'%c'", binds.ExKey.Rune), + )) + result = append(result, fmt.Sprintf(fmtStr, + "Globals", + fmt.Sprintf("%v", binds.Globals), + )) + sort.Strings(result) + return result +} + +func (aerc *Aerc) getBindings() *config.KeyBindings { + selectedAccountName := "" + if aerc.SelectedAccount() != nil { + selectedAccountName = aerc.SelectedAccount().acct.Name + } + switch view := aerc.SelectedTabContent().(type) { + case *AccountView: + binds := config.Binds.MessageList.ForAccount(selectedAccountName) + return binds.ForFolder(view.SelectedDirectory()) + case *AccountWizard: + return config.Binds.AccountWizard + case *Composer: + switch view.Bindings() { + case "compose::editor": + return config.Binds.ComposeEditor.ForAccount( + selectedAccountName) + case "compose::review": + return config.Binds.ComposeReview.ForAccount( + selectedAccountName) + default: + return config.Binds.Compose.ForAccount( + selectedAccountName) + } + case *MessageViewer: + switch view.Bindings() { + case "view::passthrough": + return config.Binds.MessageViewPassthrough.ForAccount( + selectedAccountName) + default: + return config.Binds.MessageView.ForAccount( + selectedAccountName) + } + case *Terminal: + return config.Binds.Terminal + default: + return config.Binds.Global + } +} + +func (aerc *Aerc) simulate(strokes []config.KeyStroke) { + aerc.pendingKeys = []config.KeyStroke{} + aerc.simulating += 1 + for _, stroke := range strokes { + simulated := tcell.NewEventKey( + stroke.Key, stroke.Rune, tcell.ModNone) + aerc.Event(simulated) + } + aerc.simulating -= 1 + // If we are still focused on the exline, turn on tab complete + if exline, ok := aerc.focused.(*ExLine); ok { + exline.TabComplete(func(cmd string) ([]string, string) { + return aerc.complete(cmd) + }) + // send tab to text input to trigger completion + exline.Event(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)) + } +} + +func (aerc *Aerc) Event(event tcell.Event) bool { + if aerc.dialog != nil { + return aerc.dialog.Event(event) + } + + if aerc.focused != nil { + return aerc.focused.Event(event) + } + + switch event := event.(type) { + case *tcell.EventKey: + // If we are in a bracketed paste, don't process the keys for + // bindings + if aerc.pasting { + interactive, ok := aerc.SelectedTabContent().(ui.Interactive) + if ok { + return interactive.Event(event) + } + return false + } + aerc.statusline.Expire() + aerc.pendingKeys = append(aerc.pendingKeys, config.KeyStroke{ + Modifiers: event.Modifiers(), + Key: event.Key(), + Rune: event.Rune(), + }) + ui.Invalidate() + bindings := aerc.getBindings() + incomplete := false + result, strokes := bindings.GetBinding(aerc.pendingKeys) + switch result { + case config.BINDING_FOUND: + aerc.simulate(strokes) + return true + case config.BINDING_INCOMPLETE: + incomplete = true + case config.BINDING_NOT_FOUND: + } + if bindings.Globals { + result, strokes = config.Binds.Global.GetBinding(aerc.pendingKeys) + switch result { + case config.BINDING_FOUND: + aerc.simulate(strokes) + return true + case config.BINDING_INCOMPLETE: + incomplete = true + case config.BINDING_NOT_FOUND: + } + } + if !incomplete { + aerc.pendingKeys = []config.KeyStroke{} + exKey := bindings.ExKey + if aerc.simulating > 0 { + // Keybindings still use : even if you change the ex key + exKey = config.Binds.Global.ExKey + } + if aerc.isExKey(event, exKey) { + aerc.BeginExCommand("") + return true + } + interactive, ok := aerc.SelectedTabContent().(ui.Interactive) + if ok { + return interactive.Event(event) + } + return false + } + case *tcell.EventMouse: + x, y := event.Position() + aerc.grid.MouseEvent(x, y, event) + return true + case *tcell.EventPaste: + if event.Start() { + aerc.pasting = true + } + if event.End() { + aerc.pasting = false + } + interactive, ok := aerc.SelectedTabContent().(ui.Interactive) + if ok { + return interactive.Event(event) + } + return false + } + return false +} + +func (aerc *Aerc) SelectedAccount() *AccountView { + return aerc.account(aerc.SelectedTabContent()) +} + +func (aerc *Aerc) Account(name string) (*AccountView, error) { + if acct, ok := aerc.accounts[name]; ok { + return acct, nil + } + return nil, fmt.Errorf("account <%s> not found", name) +} + +func (aerc *Aerc) PrevAccount() (*AccountView, error) { + cur := aerc.SelectedAccount() + if cur == nil { + return nil, fmt.Errorf("no account selected, cannot get prev") + } + for i, conf := range config.Accounts { + if conf.Name == cur.Name() { + i -= 1 + if i == -1 { + i = len(config.Accounts) - 1 + } + conf = config.Accounts[i] + return aerc.Account(conf.Name) + } + } + return nil, fmt.Errorf("no prev account") +} + +func (aerc *Aerc) NextAccount() (*AccountView, error) { + cur := aerc.SelectedAccount() + if cur == nil { + return nil, fmt.Errorf("no account selected, cannot get next") + } + for i, conf := range config.Accounts { + if conf.Name == cur.Name() { + i += 1 + if i == len(config.Accounts) { + i = 0 + } + conf = config.Accounts[i] + return aerc.Account(conf.Name) + } + } + return nil, fmt.Errorf("no next account") +} + +func (aerc *Aerc) AccountNames() []string { + results := make([]string, 0) + for name := range aerc.accounts { + results = append(results, name) + } + return results +} + +func (aerc *Aerc) account(d ui.Drawable) *AccountView { + switch tab := d.(type) { + case *AccountView: + return tab + case *MessageViewer: + return tab.SelectedAccount() + case *Composer: + return tab.Account() + } + return nil +} + +func (aerc *Aerc) SelectedAccountUiConfig() *config.UIConfig { + acct := aerc.SelectedAccount() + if acct == nil { + return config.Ui + } + return acct.UiConfig() +} + +func (aerc *Aerc) SelectedTabContent() ui.Drawable { + tab := aerc.tabs.Selected() + if tab == nil { + return nil + } + return tab.Content +} + +func (aerc *Aerc) SelectedTab() *ui.Tab { + return aerc.tabs.Selected() +} + +func (aerc *Aerc) NewTab(clickable ui.Drawable, name string) *ui.Tab { + uiConf := config.Ui + if acct := aerc.account(clickable); acct != nil { + uiConf = acct.UiConfig() + } + tab := aerc.tabs.Add(clickable, name, uiConf) + aerc.UpdateStatus() + return tab +} + +func (aerc *Aerc) RemoveTab(tab ui.Drawable, closeContent bool) { + aerc.tabs.Remove(tab) + aerc.UpdateStatus() + if content, ok := tab.(ui.Closeable); ok && closeContent { + content.Close() + } +} + +func (aerc *Aerc) ReplaceTab(tabSrc ui.Drawable, tabTarget ui.Drawable, name string, closeSrc bool) { + aerc.tabs.Replace(tabSrc, tabTarget, name) + if content, ok := tabSrc.(ui.Closeable); ok && closeSrc { + content.Close() + } +} + +func (aerc *Aerc) MoveTab(i int, relative bool) { + aerc.tabs.MoveTab(i, relative) +} + +func (aerc *Aerc) PinTab() { + aerc.tabs.PinTab() +} + +func (aerc *Aerc) UnpinTab() { + aerc.tabs.UnpinTab() +} + +func (aerc *Aerc) NextTab() { + aerc.tabs.NextTab() +} + +func (aerc *Aerc) PrevTab() { + aerc.tabs.PrevTab() +} + +func (aerc *Aerc) SelectTab(name string) bool { + ok := aerc.tabs.SelectName(name) + if ok { + aerc.UpdateStatus() + } + return ok +} + +func (aerc *Aerc) SelectTabIndex(index int) bool { + ok := aerc.tabs.Select(index) + if ok { + aerc.UpdateStatus() + } + return ok +} + +func (aerc *Aerc) TabNames() []string { + return aerc.tabs.Names() +} + +func (aerc *Aerc) SelectPreviousTab() bool { + return aerc.tabs.SelectPrevious() +} + +func (aerc *Aerc) UpdateStatus() { + if acct := aerc.SelectedAccount(); acct != nil { + aerc.statusline.Update(acct) + } else { + aerc.statusline.Clear() + } +} + +func (aerc *Aerc) SetError(err string) { + aerc.statusline.SetError(err) +} + +func (aerc *Aerc) PushStatus(text string, expiry time.Duration) *StatusMessage { + return aerc.statusline.Push(text, expiry) +} + +func (aerc *Aerc) PushError(text string) *StatusMessage { + return aerc.statusline.PushError(text) +} + +func (aerc *Aerc) PushWarning(text string) *StatusMessage { + return aerc.statusline.PushWarning(text) +} + +func (aerc *Aerc) PushSuccess(text string) *StatusMessage { + return aerc.statusline.PushSuccess(text) +} + +func (aerc *Aerc) focus(item ui.Interactive) { + if aerc.focused == item { + return + } + if aerc.focused != nil { + aerc.focused.Focus(false) + } + aerc.focused = item + interactive, ok := aerc.SelectedTabContent().(ui.Interactive) + if item != nil { + item.Focus(true) + if ok { + interactive.Focus(false) + } + } else if ok { + interactive.Focus(true) + } +} + +func (aerc *Aerc) BeginExCommand(cmd string) { + previous := aerc.focused + var tabComplete func(string) ([]string, string) + if aerc.simulating != 0 { + // Don't try to draw completions for simulated events + tabComplete = nil + } else { + tabComplete = func(cmd string) ([]string, string) { + return aerc.complete(cmd) + } + } + exline := NewExLine(cmd, func(cmd string) { + parts, err := shlex.Split(cmd) + if err != nil { + aerc.PushError(err.Error()) + } + err = aerc.cmd(parts, nil, nil) + if err != nil { + aerc.PushError(err.Error()) + } + // only add to history if this is an unsimulated command, + // ie one not executed from a keybinding + if aerc.simulating == 0 { + aerc.cmdHistory.Add(cmd) + } + }, func() { + aerc.statusbar.Pop() + aerc.focus(previous) + }, tabComplete, aerc.cmdHistory) + aerc.statusbar.Push(exline) + aerc.focus(exline) +} + +func (aerc *Aerc) PushPrompt(prompt *ExLine) { + aerc.prompts.Push(prompt) +} + +func (aerc *Aerc) RegisterPrompt(prompt string, cmd []string) { + p := NewPrompt(prompt, func(text string) { + if text != "" { + cmd = append(cmd, text) + } + err := aerc.cmd(cmd, nil, nil) + if err != nil { + aerc.PushError(err.Error()) + } + }, func(cmd string) ([]string, string) { + return nil, "" // TODO: completions + }) + aerc.prompts.Push(p) +} + +func (aerc *Aerc) RegisterChoices(choices []Choice) { + cmds := make(map[string][]string) + texts := []string{} + for _, c := range choices { + text := fmt.Sprintf("[%s] %s", c.Key, c.Text) + if strings.Contains(c.Text, c.Key) { + text = strings.Replace(c.Text, c.Key, "["+c.Key+"]", 1) + } + texts = append(texts, text) + cmds[c.Key] = c.Command + } + prompt := strings.Join(texts, ", ") + "? " + p := NewPrompt(prompt, func(text string) { + cmd, ok := cmds[text] + if !ok { + return + } + err := aerc.cmd(cmd, nil, nil) + if err != nil { + aerc.PushError(err.Error()) + } + }, func(cmd string) ([]string, string) { + return nil, "" // TODO: completions + }) + aerc.prompts.Push(p) +} + +func (aerc *Aerc) Mailto(addr *url.URL) error { + var subject string + var body string + var acctName string + var attachments []string + h := &mail.Header{} + to, err := mail.ParseAddressList(addr.Opaque) + if err != nil && addr.Opaque != "" { + return fmt.Errorf("Could not parse to: %w", err) + } + h.SetAddressList("to", to) + template := config.Templates.NewMessage + for key, vals := range addr.Query() { + switch strings.ToLower(key) { + case "account": + acctName = strings.Join(vals, "") + case "bcc": + list, err := mail.ParseAddressList(strings.Join(vals, ",")) + if err != nil { + break + } + h.SetAddressList("Bcc", list) + case "body": + body = strings.Join(vals, "\n") + case "cc": + list, err := mail.ParseAddressList(strings.Join(vals, ",")) + if err != nil { + break + } + h.SetAddressList("Cc", list) + case "in-reply-to": + for i, msgID := range vals { + if len(msgID) > 1 && msgID[0] == '<' && + msgID[len(msgID)-1] == '>' { + vals[i] = msgID[1 : len(msgID)-1] + } + } + h.SetMsgIDList("In-Reply-To", vals) + case "subject": + subject = strings.Join(vals, ",") + h.SetText("Subject", subject) + case "template": + template = strings.Join(vals, "") + log.Tracef("template set to %s", template) + case "attach": + for _, path := range vals { + // remove a potential file:// prefix. + attachments = append(attachments, strings.TrimPrefix(path, "file://")) + } + default: + // any other header gets ignored on purpose to avoid control headers + // being injected + } + } + + acct := aerc.SelectedAccount() + if acctName != "" { + if a, ok := aerc.accounts[acctName]; ok && a != nil { + acct = a + } + } + + if acct == nil { + return errors.New("No account selected") + } + + defer ui.Invalidate() + + composer, err := NewComposer(aerc, acct, + acct.AccountConfig(), acct.Worker(), + config.Compose.EditHeaders, template, h, nil, + strings.NewReader(body)) + if err != nil { + return err + } + composer.FocusEditor("subject") + title := "New email" + if subject != "" { + title = subject + composer.FocusTerminal() + } + if to == nil { + composer.FocusEditor("to") + } + composer.Tab = aerc.NewTab(composer, title) + + for _, file := range attachments { + composer.AddAttachment(file) + } + return nil +} + +func (aerc *Aerc) Mbox(source string) error { + acctConf := config.AccountConfig{} + if selectedAcct := aerc.SelectedAccount(); selectedAcct != nil { + acctConf = *selectedAcct.acct + info := fmt.Sprintf("Loading outgoing mbox mail settings from account [%s]", selectedAcct.Name()) + aerc.PushStatus(info, 10*time.Second) + log.Debugf(info) + } else { + acctConf.From = &mail.Address{Address: "user@localhost"} + } + acctConf.Name = "mbox" + acctConf.Source = source + acctConf.Default = "INBOX" + acctConf.Archive = "Archive" + acctConf.Postpone = "Drafts" + acctConf.CopyTo = "Sent" + + defer ui.Invalidate() + + mboxView, err := NewAccountView(aerc, &acctConf, aerc, nil) + if err != nil { + aerc.NewTab(errorScreen(err.Error()), acctConf.Name) + } else { + aerc.accounts[acctConf.Name] = mboxView + aerc.NewTab(mboxView, acctConf.Name) + } + return nil +} + +func (aerc *Aerc) Command(args []string) error { + defer ui.Invalidate() + return aerc.cmd(args, nil, nil) +} + +func (aerc *Aerc) CloseBackends() error { + var returnErr error + for _, acct := range aerc.accounts { + var raw interface{} = acct.worker.Backend + c, ok := raw.(io.Closer) + if !ok { + continue + } + err := c.Close() + if err != nil { + returnErr = err + log.Errorf("Closing backend failed for %s: %v", acct.Name(), err) + } + } + return returnErr +} + +func (aerc *Aerc) AddDialog(d ui.DrawableInteractive) { + aerc.dialog = d + aerc.Invalidate() +} + +func (aerc *Aerc) CloseDialog() { + aerc.dialog = nil + aerc.Invalidate() +} + +func (aerc *Aerc) GetPassword(title string, prompt string) (chText chan string, chErr chan error) { + chText = make(chan string, 1) + chErr = make(chan error, 1) + getPasswd := NewGetPasswd(title, prompt, func(pw string, err error) { + defer func() { + close(chErr) + close(chText) + aerc.CloseDialog() + }() + if err != nil { + chErr <- err + return + } + chErr <- nil + chText <- pw + }) + aerc.AddDialog(getPasswd) + + return +} + +func (aerc *Aerc) Initialize(ui *ui.UI) { + aerc.ui = ui +} + +func (aerc *Aerc) DecryptKeys(keys []openpgp.Key, symmetric bool) (b []byte, err error) { + for _, key := range keys { + ident := key.Entity.PrimaryIdentity() + chPass, chErr := aerc.GetPassword("Decrypt PGP private key", + fmt.Sprintf("Enter password for %s (%8X)\nPress <ESC> to cancel", + ident.Name, key.PublicKey.KeyId)) + + for err := range chErr { + if err != nil { + return nil, err + } + pass := <-chPass + err = key.PrivateKey.Decrypt([]byte(pass)) + return nil, err + } + } + return nil, err +} + +// errorScreen is a widget that draws an error in the middle of the context +func errorScreen(s string) ui.Drawable { + errstyle := config.Ui.GetStyle(config.STYLE_ERROR) + text := ui.NewText(s, errstyle).Strategy(ui.TEXT_CENTER) + grid := ui.NewGrid().Rows([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }).Columns([]ui.GridSpec{ + {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}, + }) + grid.AddChild(ui.NewFill(' ', tcell.StyleDefault)).At(0, 0) + grid.AddChild(text).At(1, 0) + grid.AddChild(ui.NewFill(' ', tcell.StyleDefault)).At(2, 0) + return grid +} + +func (aerc *Aerc) isExKey(event *tcell.EventKey, exKey config.KeyStroke) bool { + if event.Key() == tcell.KeyRune { + // Compare runes if it's a KeyRune + return event.Modifiers() == exKey.Modifiers && event.Rune() == exKey.Rune + } + return event.Modifiers() == exKey.Modifiers && event.Key() == exKey.Key +} + +// CmdFallbackSearch checks cmds for the first executable availabe in PATH. An error is +// returned if none are found +func (aerc *Aerc) CmdFallbackSearch(cmds []string) (string, error) { + var tried []string + for _, cmd := range cmds { + if cmd == "" { + continue + } + params := strings.Split(cmd, " ") + _, err := exec.LookPath(params[0]) + if err != nil { + tried = append(tried, cmd) + warn := fmt.Sprintf("cmd '%s' not found in PATH, using fallback", cmd) + aerc.PushWarning(warn) + continue + } + return cmd, nil + } + return "", fmt.Errorf("no command found in PATH: %s", tried) +} |