From 0df5effdcf4f5eb09a724400369e93a585c24188 Mon Sep 17 00:00:00 2001 From: Stephen Robinson Date: Mon, 14 Dec 2020 11:28:07 -0800 Subject: [PATCH] Mediatypes support (#134) Co-authored-by: makeworld <25111343+makeworld-the-better-one@users.noreply.github.com> Co-authored-by: Stephen Robinson --- README.md | 2 + config/config.go | 60 +++++++++++++ config/default.go | 48 +++++++++++ config/default.sh | 8 +- default-config.toml | 48 +++++++++++ display/download.go | 145 ++++++++++++++++++++++---------- sysopen/open_browser_darwin.go | 14 +++ sysopen/open_browser_other.go | 11 +++ sysopen/open_browser_unix.go | 35 ++++++++ sysopen/open_browser_windows.go | 15 ++++ 10 files changed, 339 insertions(+), 47 deletions(-) create mode 100644 sysopen/open_browser_darwin.go create mode 100644 sysopen/open_browser_other.go create mode 100644 sysopen/open_browser_unix.go create mode 100644 sysopen/open_browser_windows.go diff --git a/README.md b/README.md index 1e37e50..252a6dc 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ Features in *italics* are in the master branch, but not in the latest release. - Disabled by default, enable in config - [x] Proxying - Schemes like Gopher or HTTP can be proxied through a Gemini server +- [x] *Configure applications to open particular mediatypes* + - [ ] Allow piping/streaming content instead of downloading it first - [x] Client certificate support - [ ] Full client certificate UX within the client - Create transient and permanent certs within the client, per domain diff --git a/config/config.go b/config/config.go index 26f5938..9f9cd71 100644 --- a/config/config.go +++ b/config/config.go @@ -38,6 +38,7 @@ var bkmkDir string var bkmkPath string var DownloadsDir string +var TempDownloadsDir string // Subscriptions var subscriptionDir string @@ -46,6 +47,13 @@ var SubscriptionPath string // Command for opening HTTP(S) URLs in the browser, from "a-general.http" in config. var HTTPCommand []string +type MediaHandler struct { + Cmd []string + NoPrompt bool +} + +var MediaHandlers = make(map[string]MediaHandler) + func Init() error { // *** Set paths *** @@ -194,6 +202,36 @@ func Init() error { DownloadsDir = dDir } + // Setup temporary downloads dir + if viper.GetString("a-general.temp_downloads") == "" { + TempDownloadsDir = filepath.Join(os.TempDir(), "amfora_temp") + + // Make sure it exists + err = os.MkdirAll(TempDownloadsDir, 0755) + if err != nil { + return fmt.Errorf("temp downloads path could not be created: %s", TempDownloadsDir) + } + } else { + // Validate path + dDir := viper.GetString("a-general.temp_downloads") + di, err := os.Stat(dDir) + if err == nil { + if !di.IsDir() { + return fmt.Errorf("temp downloads path specified is not a directory: %s", dDir) + } + } else if os.IsNotExist(err) { + // Try to create path + err = os.MkdirAll(dDir, 0755) + if err != nil { + return fmt.Errorf("temp downloads path could not be created: %s", dDir) + } + } else { + // Some other error + return fmt.Errorf("couldn't access temp downloads directory: %s", dDir) + } + TempDownloadsDir = dDir + } + // *** Setup vipers *** TofuStore.SetConfigFile(tofuDBPath) @@ -228,6 +266,7 @@ func Init() error { viper.SetDefault("a-general.left_margin", 0.15) viper.SetDefault("a-general.max_width", 100) viper.SetDefault("a-general.downloads", "") + viper.SetDefault("a-general.temp_downloads", "") viper.SetDefault("a-general.page_max_size", 2097152) viper.SetDefault("a-general.page_max_time", 10) viper.SetDefault("a-general.emoji_favicons", false) @@ -279,5 +318,26 @@ func Init() error { HTTPCommand = strings.Fields(viper.GetString("a-general.http")) } + var rawMediaHandlers []struct { + Cmd []string `mapstructure:"cmd"` + Types []string `mapstructure:"types"` + NoPrompt bool `mapstructure:"no_prompt"` + } + err = viper.UnmarshalKey("mediatype-handlers", &rawMediaHandlers) + if err != nil { + return fmt.Errorf("couldn't parse mediatype-handlers section in config: %w", err) + } + for _, rawMediaHandler := range rawMediaHandlers { + for _, typ := range rawMediaHandler.Types { + if _, ok := MediaHandlers[typ]; ok { + return fmt.Errorf("multiple mediatype-handlers defined for %v", typ) + } + MediaHandlers[typ] = MediaHandler{ + Cmd: rawMediaHandler.Cmd, + NoPrompt: rawMediaHandler.NoPrompt, + } + } + } + return nil } diff --git a/config/default.go b/config/default.go index b217428..4fbe02c 100644 --- a/config/default.go +++ b/config/default.go @@ -115,6 +115,54 @@ shift_numbers = "!@#$%^&*()" other = 'off' +# [[mediatype-handlers]] +# Specify what applications will open certain media types. +# By default your default application will be used to open the file when you select "Open". +# You only need to configure this section if you want to override your default application, +# or do special things like streaming. +# +# To open jpeg files with the feh command: +# [[mediatype-handlers]] +# cmd = ["feh"] +# types = ["image/jpeg"] +# +# Each command that you specify must come under its own [[mediatype-handlers]]. You may +# specify as many [[mediatype-handlers]] as you want to setup multiple commands. +# +# If the subtype is omitted then the specified command will be used for the +# entire type: +# [[mediatype-handlers]] +# command = ["vlc", "--flag"] +# types = ["audio", "video"] +# +# A catch-all handler can by specified with "*". +# Note that there are already catch-all handlers in place for all OSes, +# that open the file using your default application. This is only if you +# want to override that. +# [[mediatype-handlers]] +# cmd = ["some-command"] +# types = [ +# "application/pdf", +# "*", +# ] +# +# If you want to always open a type in its viewer without the download or open +# prompt appearing, you can add no_prompt = true +# +# [[mediatype-handlers]] +# cmd = ["feh"] +# types = ["image"] +# no_prompt = true +# +# Note: Multiple handlers cannot be defined for the same full media type, but +# still there needs to be an order for which handlers are used. The following +# order applies regardless of the order written in the config: +# +# 1. Full media type: "image/jpeg" +# 2. Just type: "image" +# 3. Catch-all: "*" + + [cache] # Options for page cache - which is only for text/gemini pages # Increase the cache size to speed up browsing at the expense of memory diff --git a/config/default.sh b/config/default.sh index 3de772f..b7f96bb 100755 --- a/config/default.sh +++ b/config/default.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash -head -n 3 default.go | tee default.go > /dev/null +cat > default.go <<-EOF +package config + +//go:generate ./default.sh +EOF echo -n 'var defaultConf = []byte(`' >> default.go cat ../default-config.toml >> default.go -echo '`)' >> default.go \ No newline at end of file +echo '`)' >> default.go diff --git a/default-config.toml b/default-config.toml index 5dca13a..9d3cf84 100644 --- a/default-config.toml +++ b/default-config.toml @@ -112,6 +112,54 @@ shift_numbers = "!@#$%^&*()" other = 'off' +# [[mediatype-handlers]] +# Specify what applications will open certain media types. +# By default your default application will be used to open the file when you select "Open". +# You only need to configure this section if you want to override your default application, +# or do special things like streaming. +# +# To open jpeg files with the feh command: +# [[mediatype-handlers]] +# cmd = ["feh"] +# types = ["image/jpeg"] +# +# Each command that you specify must come under its own [[mediatype-handlers]]. You may +# specify as many [[mediatype-handlers]] as you want to setup multiple commands. +# +# If the subtype is omitted then the specified command will be used for the +# entire type: +# [[mediatype-handlers]] +# command = ["vlc", "--flag"] +# types = ["audio", "video"] +# +# A catch-all handler can by specified with "*". +# Note that there are already catch-all handlers in place for all OSes, +# that open the file using your default application. This is only if you +# want to override that. +# [[mediatype-handlers]] +# cmd = ["some-command"] +# types = [ +# "application/pdf", +# "*", +# ] +# +# If you want to always open a type in its viewer without the download or open +# prompt appearing, you can add no_prompt = true +# +# [[mediatype-handlers]] +# cmd = ["feh"] +# types = ["image"] +# no_prompt = true +# +# Note: Multiple handlers cannot be defined for the same full media type, but +# still there needs to be an order for which handlers are used. The following +# order applies regardless of the order written in the config: +# +# 1. Full media type: "image/jpeg" +# 2. Just type: "image" +# 3. Catch-all: "*" + + [cache] # Options for page cache - which is only for text/gemini pages # Increase the cache size to speed up browsing at the expense of memory diff --git a/display/download.go b/display/download.go index 6a30b4e..5f5e3b6 100644 --- a/display/download.go +++ b/display/download.go @@ -4,8 +4,10 @@ import ( "fmt" "io" "io/ioutil" + "mime" "net/url" "os" + "os/exec" "path" "path/filepath" "strconv" @@ -15,15 +17,16 @@ import ( "github.com/gdamore/tcell" "github.com/makeworld-the-better-one/amfora/config" "github.com/makeworld-the-better-one/amfora/structs" + "github.com/makeworld-the-better-one/amfora/sysopen" "github.com/makeworld-the-better-one/go-gemini" "github.com/makeworld-the-better-one/progressbar/v3" "github.com/spf13/viper" "gitlab.com/tslocum/cview" ) -// For choosing between download and the portal - copy of YesNo basically +// For choosing between download and opening - copy of YesNo basically var dlChoiceModal = cview.NewModal(). - AddButtons([]string{"Download", "Open in portal", "Cancel"}) + AddButtons([]string{"Open", "Download", "Cancel"}) // Channel to indicate what choice they made using the button text var dlChoiceCh = make(chan string) @@ -83,46 +86,62 @@ func dlInit() { }) } +func getMediaHandler(resp *gemini.Response) config.MediaHandler { + def := config.MediaHandler{ + Cmd: nil, + NoPrompt: false, + } + + mediatype, _, err := mime.ParseMediaType(resp.Meta) + if err != nil { + return def + } + + if ret, ok := config.MediaHandlers[mediatype]; ok { + return ret + } + + splitType := strings.Split(mediatype, "/")[0] + if ret, ok := config.MediaHandlers[splitType]; ok { + return ret + } + + if ret, ok := config.MediaHandlers["*"]; ok { + return ret + } + + return def +} + // dlChoice displays the download choice modal and acts on the user's choice. // It should run in a goroutine. func dlChoice(text, u string, resp *gemini.Response) { defer resp.Body.Close() - parsed, err := url.Parse(u) - if err != nil { - Error("URL Error", err.Error()) - return + mediaHandler := getMediaHandler(resp) + var choice string + + if mediaHandler.NoPrompt { + choice = "Open" + } else { + dlChoiceModal.SetText(text) + tabPages.ShowPage("dlChoice") + tabPages.SendToFront("dlChoice") + App.SetFocus(dlChoiceModal) + App.Draw() + choice = <-dlChoiceCh } - dlChoiceModal.SetText(text) - tabPages.ShowPage("dlChoice") - tabPages.SendToFront("dlChoice") - App.SetFocus(dlChoiceModal) - App.Draw() - - choice := <-dlChoiceCh if choice == "Download" { tabPages.HidePage("dlChoice") App.Draw() - downloadURL(u, resp) + downloadURL(config.DownloadsDir, u, resp) return } - if choice == "Open in portal" { - // Open in mozz's proxy - portalURL := u - if parsed.RawQuery != "" { - // Remove query and add encoded version on the end - query := parsed.RawQuery - parsed.RawQuery = "" - portalURL = parsed.String() + "%3F" + query - } - portalURL = strings.TrimPrefix(portalURL, "gemini://") + "?raw=1" - ok := handleHTTP("https://portal.mozz.us/gemini/"+portalURL, false) - if ok { - tabPages.SwitchToPage(strconv.Itoa(curTab)) - App.SetFocus(tabs[curTab].view) - App.Draw() - } + if choice == "Open" { + tabPages.HidePage("dlChoice") + App.Draw() + open(u, resp) return } tabPages.SwitchToPage(strconv.Itoa(curTab)) @@ -130,9 +149,43 @@ func dlChoice(text, u string, resp *gemini.Response) { App.Draw() } +// open performs the same actions as downloadURL except it also opens the file. +// If there is no system viewer configured for the particular mediatype, it opens it +// with the default system viewer. +func open(u string, resp *gemini.Response) { + mediaHandler := getMediaHandler(resp) + path := downloadURL(config.TempDownloadsDir, u, resp) + if path == "" { + return + } + tabPages.SwitchToPage(strconv.Itoa(curTab)) + App.SetFocus(tabs[curTab].view) + App.Draw() + if mediaHandler.Cmd == nil { + // Open with system default viewer + _, err := sysopen.Open(path) + if err != nil { + Error("System Viewer Error", err.Error()) + return + } + Info("Opened in default system viewer") + } else { + cmd := mediaHandler.Cmd + err := exec.Command(cmd[0], append(cmd[1:], path)...).Start() + if err != nil { + Error("File Opening Error", "Error executing custom command: "+err.Error()) + return + } + Info("Opened with " + cmd[0]) + } + App.SetFocus(dlModal) + App.Draw() +} + // downloadURL pulls up a modal to show download progress and saves the URL content. // downloadPage should be used for Page content. -func downloadURL(u string, resp *gemini.Response) { +// Returns location downloaded to or an empty string on error. +func downloadURL(dir, u string, resp *gemini.Response) string { _, _, width, _ := dlModal.GetInnerRect() // Copy of progressbar.DefaultBytesSilent with custom width bar := progressbar.NewOptions64( @@ -146,15 +199,15 @@ func downloadURL(u string, resp *gemini.Response) { ) bar.RenderBlank() //nolint:errcheck - savePath, err := downloadNameFromURL(u, "") + savePath, err := downloadNameFromURL(dir, u, "") if err != nil { Error("Download Error", "Error deciding on file name: "+err.Error()) - return + return "" } f, err := os.OpenFile(savePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { Error("Download Error", "Error creating download file: "+err.Error()) - return + return "" } defer f.Close() @@ -184,7 +237,7 @@ func downloadURL(u string, resp *gemini.Response) { Error("Download Error", err.Error()) f.Close() os.Remove(savePath) // Remove partial file - return + return "" } dlModal.SetText(fmt.Sprintf("Download complete! File saved to %s.", savePath)) dlModal.ClearButtons() @@ -192,6 +245,8 @@ func downloadURL(u string, resp *gemini.Response) { dlModal.GetForm().SetFocus(100) App.SetFocus(dlModal) App.Draw() + + return savePath } // downloadPage saves the passed Page to a file. @@ -202,9 +257,9 @@ func downloadPage(p *structs.Page) (string, error) { var err error if p.Mediatype == structs.TextGemini { - savePath, err = downloadNameFromURL(p.URL, ".gmi") + savePath, err = downloadNameFromURL(config.DownloadsDir, p.URL, ".gmi") } else { - savePath, err = downloadNameFromURL(p.URL, ".txt") + savePath, err = downloadNameFromURL(config.DownloadsDir, p.URL, ".txt") } if err != nil { return "", err @@ -221,13 +276,13 @@ func downloadPage(p *structs.Page) (string, error) { // downloadNameFromURL takes a URl and returns a safe download path that will not overwrite any existing file. // ext is an extension that will be added if the file has no extension, and for domain only URLs. // It should include the dot. -func downloadNameFromURL(u string, ext string) (string, error) { +func downloadNameFromURL(dir, u, ext string) (string, error) { var name string var err error parsed, _ := url.Parse(u) if parsed.Path == "" || path.Base(parsed.Path) == "/" { // No file, just the root domain - name, err = getSafeDownloadName(parsed.Hostname()+ext, true, 0) + name, err = getSafeDownloadName(dir, parsed.Hostname()+ext, true, 0) if err != nil { return "", err } @@ -238,23 +293,23 @@ func downloadNameFromURL(u string, ext string) (string, error) { // No extension name += ext } - name, err = getSafeDownloadName(name, false, 0) + name, err = getSafeDownloadName(dir, name, false, 0) if err != nil { return "", err } } - return filepath.Join(config.DownloadsDir, name), nil + return filepath.Join(dir, name), nil } // getSafeDownloadName is used by downloads.go only. -// It returns a modified name that is unique for the downloads folder. +// It returns a modified name that is unique for the specified folder. // This way duplicate saved files will not overwrite each other. // // lastDot should be set to true if the number added to the name should come before // the last dot in the filename instead of the first. // // n should be set to 0, it is used for recursiveness. -func getSafeDownloadName(name string, lastDot bool, n int) (string, error) { +func getSafeDownloadName(dir, name string, lastDot bool, n int) (string, error) { // newName("test.txt", 3) -> "test(3).txt" newName := func() string { if n <= 0 { @@ -271,7 +326,7 @@ func getSafeDownloadName(name string, lastDot bool, n int) (string, error) { return name[:idx] + "(" + strconv.Itoa(n) + ")" + name[idx:] } - d, err := os.Open(config.DownloadsDir) + d, err := os.Open(dir) if err != nil { return "", err } @@ -285,7 +340,7 @@ func getSafeDownloadName(name string, lastDot bool, n int) (string, error) { for i := range files { if nn == files[i] { d.Close() - return getSafeDownloadName(name, lastDot, n+1) + return getSafeDownloadName(dir, name, lastDot, n+1) } } d.Close() diff --git a/sysopen/open_browser_darwin.go b/sysopen/open_browser_darwin.go new file mode 100644 index 0000000..49bd171 --- /dev/null +++ b/sysopen/open_browser_darwin.go @@ -0,0 +1,14 @@ +// +build darwin + +package sysopen + +import "os/exec" + +// Open opens `path` in default system viewer. +func Open(path string) (string, error) { + err := exec.Command("open", path).Start() + if err != nil { + return "", err + } + return "Opened in default system viewer", nil +} diff --git a/sysopen/open_browser_other.go b/sysopen/open_browser_other.go new file mode 100644 index 0000000..7644a0e --- /dev/null +++ b/sysopen/open_browser_other.go @@ -0,0 +1,11 @@ +// +build !linux,!darwin,!windows,!freebsd,!netbsd,!openbsd + +package sysopen + +import "fmt" + +// Open opens `path` in default system viewer, but not on this OS. +func Open(path string) (string, error) { + return "", fmt.Errorf("unsupported OS for default system viewer. " + + "Set a catch-all [[mediatype-handlers]] command in the config") +} diff --git a/sysopen/open_browser_unix.go b/sysopen/open_browser_unix.go new file mode 100644 index 0000000..88a215b --- /dev/null +++ b/sysopen/open_browser_unix.go @@ -0,0 +1,35 @@ +// +build linux freebsd netbsd openbsd + +//nolint:goerr113 +package sysopen + +import ( + "fmt" + "os" + "os/exec" +) + +// Open opens `path` in default system viewer. It tries to do so using +// xdg-open. It only works if there is a display server working. +func Open(path string) (string, error) { + var ( + xorgDisplay = os.Getenv("DISPLAY") + waylandDisplay = os.Getenv("WAYLAND_DISPLAY") + xdgOpenPath, xdgOpenNotFoundErr = exec.LookPath("xdg-open") + ) + switch { + case xorgDisplay == "" && waylandDisplay == "": + return "", fmt.Errorf("no display server was found. " + + "You may set a default [[mediatype-handlers]] command in the config") + case xdgOpenNotFoundErr == nil: + // Use start rather than run or output in order + // to make application run in background. + if err := exec.Command(xdgOpenPath, path).Start(); err != nil { + return "", err + } + return "Opened in default system viewer", nil + default: + return "", fmt.Errorf("could not determine default system viewer. " + + "Set a catch-all [[mediatype-handlers]] command in the config") + } +} diff --git a/sysopen/open_browser_windows.go b/sysopen/open_browser_windows.go new file mode 100644 index 0000000..4924ea8 --- /dev/null +++ b/sysopen/open_browser_windows.go @@ -0,0 +1,15 @@ +// +build windows +// +build !linux !darwin !freebsd !netbsd !openbsd + +package sysopen + +import "os/exec" + +// Open opens `path` in default system vierwer. +func Open(path string) (string, error) { + err := exec.Command("rundll32", "url.dll,FileProtocolHandler", path).Start() + if err != nil { + return "", err + } + return "Opened in default system viewer", nil +}