aerc/widgets/msgviewer.go
Simon Ser 5685a17674 lib/ui: introduce Invalidatable
Many Drawable implementations have their own Invalidate and OnInvalidate
functions, with an unexported onInvalidate field. However OnInvalidate and
Invalidate are usually not called in the same goroutine. This results in a race
on this field, e.g.:

    Read at 0x00c000094748 by goroutine 7:
      git.sr.ht/~sircmpwn/aerc2/widgets.NewDirectoryList.func1()
          /home/simon/src/aerc2/widgets/dirlist.go:85 +0x56
      git.sr.ht/~sircmpwn/aerc2/widgets.(*Spinner).Start.func1()
          /home/simon/src/aerc2/widgets/spinner.go:93 +0x1bb

    Previous write at 0x00c000094748 by main goroutine:
      [failed to restore the stack]

    Goroutine 7 (running) created at:
      git.sr.ht/~sircmpwn/aerc2/widgets.(*Spinner).Start()
          /home/simon/src/aerc2/widgets/spinner.go:46 +0x8f
      git.sr.ht/~sircmpwn/aerc2/widgets.NewDirectoryList()
          /home/simon/src/aerc2/widgets/dirlist.go:37 +0x286
      git.sr.ht/~sircmpwn/aerc2/widgets.NewAccountView()
          /home/simon/src/aerc2/widgets/account.go:50 +0x5ca
      git.sr.ht/~sircmpwn/aerc2/widgets.NewAerc()
          /home/simon/src/aerc2/widgets/aerc.go:60 +0x800
      main.main()
          /home/simon/src/aerc2/aerc.go:65 +0x33e

To fix this, introduce a new type, Invalidatable, which protects the field.
Unfortunately the Drawable must be passed to the callback function in
Invalidate, so we still need to re-implement this in each Invalidatable user.
2019-04-27 14:30:28 -04:00

300 lines
6.6 KiB
Go

package widgets
import (
"bytes"
"fmt"
"io"
"os/exec"
"strings"
"github.com/danwakefield/fnmatch"
"github.com/emersion/go-imap"
"github.com/emersion/go-message"
"github.com/emersion/go-message/mail"
"github.com/gdamore/tcell"
"github.com/google/shlex"
"github.com/mattn/go-runewidth"
"git.sr.ht/~sircmpwn/aerc2/config"
"git.sr.ht/~sircmpwn/aerc2/lib"
"git.sr.ht/~sircmpwn/aerc2/lib/ui"
"git.sr.ht/~sircmpwn/aerc2/worker/types"
)
type MessageViewer struct {
conf *config.AercConfig
err error
filter *exec.Cmd
msg *types.MessageInfo
pager *exec.Cmd
source io.Reader
pagerin io.WriteCloser
sink io.WriteCloser
grid *ui.Grid
term *Terminal
}
func formatAddresses(addrs []*imap.Address) string {
val := bytes.Buffer{}
for i, addr := range addrs {
if addr.PersonalName != "" {
val.WriteString(fmt.Sprintf("%s <%s@%s>",
addr.PersonalName, addr.MailboxName, addr.HostName))
} else {
val.WriteString(fmt.Sprintf("%s@%s",
addr.MailboxName, addr.HostName))
}
if i != len(addrs)-1 {
val.WriteString(", ")
}
}
return val.String()
}
func NewMessageViewer(conf *config.AercConfig, store *lib.MessageStore,
msg *types.MessageInfo) *MessageViewer {
grid := ui.NewGrid().Rows([]ui.GridSpec{
{ui.SIZE_EXACT, 3}, // TODO: Based on number of header rows
{ui.SIZE_WEIGHT, 1},
}).Columns([]ui.GridSpec{
{ui.SIZE_WEIGHT, 1},
})
// TODO: let user specify additional headers to show by default
headers := ui.NewGrid().Rows([]ui.GridSpec{
{ui.SIZE_EXACT, 1},
{ui.SIZE_EXACT, 1},
{ui.SIZE_EXACT, 1},
}).Columns([]ui.GridSpec{
{ui.SIZE_WEIGHT, 1},
{ui.SIZE_WEIGHT, 1},
})
headers.AddChild(
&HeaderView{
Name: "From",
Value: formatAddresses(msg.Envelope.From),
}).At(0, 0)
headers.AddChild(
&HeaderView{
Name: "To",
Value: formatAddresses(msg.Envelope.To),
}).At(0, 1)
headers.AddChild(
&HeaderView{
Name: "Subject",
Value: msg.Envelope.Subject,
}).At(1, 0).Span(1, 2)
headers.AddChild(ui.NewFill(' ')).At(2, 0).Span(1, 2)
body := ui.NewGrid().Rows([]ui.GridSpec{
{ui.SIZE_WEIGHT, 1},
}).Columns([]ui.GridSpec{
{ui.SIZE_WEIGHT, 1},
{ui.SIZE_EXACT, 20},
})
var (
filter *exec.Cmd
pager *exec.Cmd
pipe io.WriteCloser
pagerin io.WriteCloser
term *Terminal
viewer *MessageViewer
)
cmd, err := shlex.Split(conf.Viewer.Pager)
if err != nil {
goto handle_error
}
pager = exec.Command(cmd[0], cmd[1:]...)
for _, f := range conf.Filters {
mime := strings.ToLower(msg.BodyStructure.MIMEType) + "/" + strings.ToLower(msg.BodyStructure.MIMESubType)
switch f.FilterType {
case config.FILTER_MIMETYPE:
if fnmatch.Match(f.Filter, mime, 0) {
filter = exec.Command("sh", "-c", f.Command)
}
case config.FILTER_HEADER:
var header string
switch f.Header {
case "subject":
header = msg.Envelope.Subject
case "from":
header = formatAddresses(msg.Envelope.From)
case "to":
header = formatAddresses(msg.Envelope.To)
case "cc":
header = formatAddresses(msg.Envelope.Cc)
}
if f.Regex.Match([]byte(header)) {
filter = exec.Command("sh", "-c", f.Command)
}
}
if filter != nil {
break
}
}
if filter != nil {
pipe, _ = filter.StdinPipe()
pagerin, _ = pager.StdinPipe()
} else {
pipe, _ = pager.StdinPipe()
}
term, _ = NewTerminal(pager)
// TODO: configure multipart view. I left a spot for it in the grid
body.AddChild(term).At(0, 0).Span(1, 2)
grid.AddChild(headers).At(0, 0)
grid.AddChild(body).At(1, 0)
viewer = &MessageViewer{
filter: filter,
grid: grid,
msg: msg,
pager: pager,
pagerin: pagerin,
sink: pipe,
term: term,
}
store.FetchBodyPart(msg.Uid, 0, func(reader io.Reader) {
viewer.source = reader
viewer.attemptCopy()
})
term.OnStart = func() {
viewer.attemptCopy()
}
return viewer
handle_error:
viewer = &MessageViewer{
err: err,
grid: grid,
msg: msg,
}
return viewer
}
func (mv *MessageViewer) attemptCopy() {
if mv.source != nil && mv.pager.Process != nil {
header := make(message.Header)
header.Set("Content-Transfer-Encoding", mv.msg.BodyStructure.Encoding)
header.SetContentType(
mv.msg.BodyStructure.MIMEType, mv.msg.BodyStructure.Params)
header.SetContentDescription(mv.msg.BodyStructure.Description)
if mv.filter != nil {
stdout, _ := mv.filter.StdoutPipe()
mv.filter.Start()
go func() {
_, err := io.Copy(mv.pagerin, stdout)
if err != nil {
mv.err = err
mv.Invalidate()
}
mv.pagerin.Close()
stdout.Close()
}()
}
go func() {
entity, err := message.New(header, mv.source)
if err != nil {
mv.err = err
mv.Invalidate()
return
}
reader := mail.NewReader(entity)
part, err := reader.NextPart()
if err != nil {
mv.err = err
mv.Invalidate()
return
}
io.Copy(mv.sink, part.Body)
mv.sink.Close()
}()
}
}
func (mv *MessageViewer) Draw(ctx *ui.Context) {
if mv.err != nil {
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
ctx.Printf(0, 0, tcell.StyleDefault, "%s", mv.err.Error())
return
}
mv.grid.Draw(ctx)
}
func (mv *MessageViewer) Invalidate() {
mv.grid.Invalidate()
}
func (mv *MessageViewer) OnInvalidate(fn func(d ui.Drawable)) {
mv.grid.OnInvalidate(func(_ ui.Drawable) {
fn(mv)
})
}
func (mv *MessageViewer) Event(event tcell.Event) bool {
if mv.term != nil {
return mv.term.Event(event)
}
return false
}
func (mv *MessageViewer) Focus(focus bool) {
if mv.term != nil {
mv.term.Focus(focus)
}
}
type HeaderView struct {
ui.Invalidatable
Name string
Value string
}
func (hv *HeaderView) Draw(ctx *ui.Context) {
name := hv.Name
size := runewidth.StringWidth(name)
lim := ctx.Width() - size - 1
value := runewidth.Truncate(" "+hv.Value, lim, "…")
var (
hstyle tcell.Style
vstyle tcell.Style
)
// TODO: Make this more robust and less dumb
if hv.Name == "PGP" {
vstyle = tcell.StyleDefault.Foreground(tcell.ColorGreen)
hstyle = tcell.StyleDefault.Bold(true)
} else {
vstyle = tcell.StyleDefault
hstyle = tcell.StyleDefault.Bold(true)
}
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', vstyle)
ctx.Printf(0, 0, hstyle, name)
ctx.Printf(size, 0, vstyle, value)
}
func (hv *HeaderView) Invalidate() {
hv.DoInvalidate(hv)
}
type MultipartView struct {
ui.Invalidatable
}
func (mpv *MultipartView) Draw(ctx *ui.Context) {
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
ctx.Fill(0, 0, ctx.Width(), 1, ' ', tcell.StyleDefault.Reverse(true))
ctx.Printf(0, 0, tcell.StyleDefault.Reverse(true), "text/plain")
ctx.Printf(0, 1, tcell.StyleDefault, "text/html")
ctx.Printf(0, 2, tcell.StyleDefault, "application/pgp-si…")
}
func (mpv *MultipartView) Invalidate() {
mpv.DoInvalidate(mpv)
}