mirror of
https://git.sr.ht/~rjarry/aerc
synced 2025-11-29 22:33:24 +01:00
When reloading the configuration with :reload, global variables in the
config package are reset to their startup values and then, the config is
parsed from disk. While the parsing is done, these variables are
temporarily in an inconsistent and possibly invalid state.
When commands are executed interactively from aerc, they are handled by
the main goroutine which also deals with UI rendering. No UI render will
be done while :reload is in progress.
However, the IPC socket handler runs in an independent goroutine. This
has the unfortunate side effect to let the UI goroutine to run while
config parsing is in progress and causes crashes:
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x6bb142]
goroutine 1 [running]:
git.sr.ht/~rjarry/aerc/lib/log.PanicHandler()
lib/log/panic-logger.go:51 +0x6cf
panic({0xc1d960?, 0x134a6e0?})
/usr/lib/go/src/runtime/panic.go:783 +0x132
git.sr.ht/~rjarry/aerc/config.(*StyleConf).getStyle(0xc00038b908?, 0x4206b7?)
config/style.go:386 +0x42
git.sr.ht/~rjarry/aerc/config.StyleSet.Get({0x0, 0x0, 0x0, {0x0, 0x0, 0x0}}, 0x421a65?, 0x0)
config/style.go:408 +0x8b
git.sr.ht/~rjarry/aerc/config.(*UIConfig).GetStyle(...)
config/ui.go:379
git.sr.ht/~rjarry/aerc/lib/ui.(*TabStrip).Draw(0xc000314700, 0xc000192230)
lib/ui/tab.go:378 +0x15b
git.sr.ht/~rjarry/aerc/lib/ui.(*Grid).Draw(0xc000186fc0, 0xc0002c25f0)
lib/ui/grid.go:126 +0x28e
git.sr.ht/~rjarry/aerc/app.(*Aerc).Draw(0x14b9f00, 0xc0002c25f0)
app/aerc.go:192 +0x1fe
git.sr.ht/~rjarry/aerc/lib/ui.Render()
lib/ui/ui.go:155 +0x16b
main.main()
main.go:310 +0x997
Make the reload operation safe by changing how config objects are
exposed and updated. Change all objects to be atomic pointers. Expose
public functions to access their value atomically. Only update their
value after a complete and successful config parse. This way the UI
thread will always have access to a valid configuration.
NB: The account configuration is not included in this change since it
cannot be reloaded.
Fixes: https://todo.sr.ht/~rjarry/aerc/319
Reported-by: Anachron <gith@cron.world>
Signed-off-by: Robin Jarry <robin@jarry.cc>
210 lines
4.8 KiB
Go
210 lines
4.8 KiB
Go
package msgview
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.sr.ht/~rjarry/aerc/app"
|
|
"git.sr.ht/~rjarry/aerc/commands"
|
|
"git.sr.ht/~rjarry/aerc/config"
|
|
"git.sr.ht/~rjarry/aerc/lib/log"
|
|
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
|
"git.sr.ht/~rjarry/aerc/models"
|
|
)
|
|
|
|
type Save struct {
|
|
Force bool `opt:"-f" desc:"Overwrite destination path."`
|
|
CreateDirs bool `opt:"-p" desc:"Create missing directories."`
|
|
Attachments bool `opt:"-a" desc:"Save all attachments parts."`
|
|
AllAttachments bool `opt:"-A" desc:"Save all named parts."`
|
|
Path string `opt:"path" required:"false" complete:"CompletePath" desc:"Target file path."`
|
|
}
|
|
|
|
func init() {
|
|
commands.Register(Save{})
|
|
}
|
|
|
|
func (Save) Description() string {
|
|
return "Save the current message part to the given path."
|
|
}
|
|
|
|
func (Save) Context() commands.CommandContext {
|
|
return commands.MESSAGE_VIEWER
|
|
}
|
|
|
|
func (Save) Aliases() []string {
|
|
return []string{"save"}
|
|
}
|
|
|
|
func (*Save) CompletePath(arg string) []string {
|
|
defaultPath := config.General().DefaultSavePath
|
|
if defaultPath != "" && !isAbsPath(arg) {
|
|
arg = filepath.Join(defaultPath, arg)
|
|
}
|
|
return commands.CompletePath(arg, false)
|
|
}
|
|
|
|
func (s Save) Execute(args []string) error {
|
|
// we either need a path or a defaultPath
|
|
if s.Path == "" && config.General().DefaultSavePath == "" {
|
|
return errors.New("No default save path in config")
|
|
}
|
|
|
|
// Absolute paths are taken as is so that the user can override the default
|
|
// if they want to
|
|
if !isAbsPath(s.Path) {
|
|
s.Path = filepath.Join(config.General().DefaultSavePath, s.Path)
|
|
}
|
|
|
|
s.Path = xdg.ExpandHome(s.Path)
|
|
|
|
mv, ok := app.SelectedTabContent().(*app.MessageViewer)
|
|
if !ok {
|
|
return fmt.Errorf("SelectedTabContent is not a MessageViewer")
|
|
}
|
|
|
|
if s.Attachments || s.AllAttachments {
|
|
parts := mv.AttachmentParts(s.AllAttachments)
|
|
if len(parts) == 0 {
|
|
return fmt.Errorf("This message has no attachments")
|
|
}
|
|
names := make(map[string]struct{})
|
|
for _, pi := range parts {
|
|
if err := s.savePart(pi, mv, names); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
pi := mv.SelectedMessagePart()
|
|
return s.savePart(pi, mv, make(map[string]struct{}))
|
|
}
|
|
|
|
func (s *Save) savePart(
|
|
pi *app.PartInfo,
|
|
mv *app.MessageViewer,
|
|
names map[string]struct{},
|
|
) error {
|
|
path := s.Path
|
|
if s.Attachments || s.AllAttachments || isDirExists(path) {
|
|
filename := generateFilename(pi.Part)
|
|
path = filepath.Join(path, filename)
|
|
}
|
|
|
|
dir := filepath.Dir(path)
|
|
if s.CreateDirs && dir != "" {
|
|
err := os.MkdirAll(dir, 0o755)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
path = getCollisionlessFilename(path, names)
|
|
names[path] = struct{}{}
|
|
|
|
if pathExists(path) && !s.Force {
|
|
return fmt.Errorf("%q already exists and -f not given", path)
|
|
}
|
|
|
|
ch := make(chan error, 1)
|
|
mv.MessageView().FetchBodyPart(pi.Index, func(reader io.Reader) {
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
ch <- err
|
|
return
|
|
}
|
|
defer f.Close()
|
|
_, err = io.Copy(f, reader)
|
|
if err != nil {
|
|
ch <- err
|
|
return
|
|
}
|
|
ch <- nil
|
|
})
|
|
|
|
// we need to wait for the callback prior to displaying a result
|
|
go func() {
|
|
defer log.PanicHandler()
|
|
|
|
err := <-ch
|
|
if err != nil {
|
|
app.PushError(fmt.Sprintf("Save failed: %v", err))
|
|
return
|
|
}
|
|
app.PushStatus("Saved to "+path, 10*time.Second)
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func getCollisionlessFilename(path string, existing map[string]struct{}) string {
|
|
ext := filepath.Ext(path)
|
|
name := strings.TrimSuffix(path, ext)
|
|
_, exists := existing[path]
|
|
counter := 1
|
|
for exists {
|
|
path = fmt.Sprintf("%s_%d%s", name, counter, ext)
|
|
counter++
|
|
_, exists = existing[path]
|
|
}
|
|
return path
|
|
}
|
|
|
|
// isDir returns true if path is a directory and exists
|
|
func isDirExists(path string) bool {
|
|
pathinfo, err := os.Stat(path)
|
|
if err != nil {
|
|
return false // we don't really care
|
|
}
|
|
if pathinfo.IsDir() {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// pathExists returns true if path exists
|
|
func pathExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
|
|
return err == nil
|
|
}
|
|
|
|
// isAbsPath returns true if path given is anchored to / or . or ~
|
|
func isAbsPath(path string) bool {
|
|
if len(path) == 0 {
|
|
return false
|
|
}
|
|
switch path[0] {
|
|
case '/':
|
|
return true
|
|
case '.':
|
|
return true
|
|
case '~':
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// generateFilename tries to get the filename from the given part.
|
|
// if that fails it will fallback to a generated one based on the date
|
|
func generateFilename(part *models.BodyStructure) string {
|
|
filename := part.FileName()
|
|
// Some MUAs send attachments with names like /some/stupid/idea/happy.jpeg
|
|
// Assuming non hostile intent it does make sense to use just the last
|
|
// portion of the pathname as the filename for saving it.
|
|
filename = filename[strings.LastIndex(filename, "/")+1:]
|
|
switch filename {
|
|
case "", ".", "..":
|
|
timestamp := time.Now().Format("2006-01-02-150405")
|
|
filename = fmt.Sprintf("aerc_%v", timestamp)
|
|
default:
|
|
// already have a valid name
|
|
}
|
|
return filename
|
|
}
|