diff --git a/CHANGELOG.md b/CHANGELOG.md index 25e5bdc..5ac84f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Emoji favicons can now be seen if `emoji_favicons` is enabled in the config (#62) ### Changed ### Fixed - Two digit (and higher) link texts are now in line with one digit ones (#60) +- Race condition when reloading pages, could have caused the cache to still be used ## [1.4.0] - 2020-07-28 diff --git a/README.md b/README.md index 9cfadd3..074d95d 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Features in *italics* are in the master branch, but not in the latest release. - [x] Theming - [ ] Subscribe to RSS and Atom feeds and display them - Subscribing to page changes, similar to how Spacewalk works, will also be supported -- [ ] Emoji favicons +- [x] *Emoji favicons* - See `gemini://mozz.us/files/rfc_gemini_favicon.gmi` for details - [ ] Stream support - [ ] Full client certificate UX within the client diff --git a/cache/favicons.go b/cache/favicons.go new file mode 100644 index 0000000..a885837 --- /dev/null +++ b/cache/favicons.go @@ -0,0 +1,58 @@ +package cache + +import ( + "fmt" + "sync" +) + +// Functions for caching emoji favicons. +// See gemini://mozz.us/files/rfc_gemini_favicon.gmi for details. + +var favicons = make(map[string]string) // domain to emoji +var favMu = sync.RWMutex{} + +var KnownNoFavicon = "no" + +// AddFavicon will add an emoji to the cache under that host. +// It does not verify that the string passed is actually an emoji. +// You can pass KnownNoFavicon as the emoji when a host doesn't have a valid favicon. +func AddFavicon(host, emoji string) { + favMu.Lock() + favicons[host] = emoji + favMu.Unlock() +} + +// ClearFavicons removes all favicons from the cache +func ClearFavicons() { + favMu.Lock() + favicons = make(map[string]string) + favMu.Unlock() +} + +// GetFavicon returns the favicon string for the host. +// It returns an empty string if there is no favicon cached. +// It might also return KnownNoFavicon to indicate that that host does not have +// a favicon at all. +func GetFavicon(host string) string { + favMu.RLock() + defer favMu.RUnlock() + return favicons[host] +} + +func NumFavicons() int { + favMu.RLock() + defer favMu.RUnlock() + return len(favicons) +} + +func RemoveFavicon(host string) { + favMu.Lock() + delete(favicons, host) + favMu.Unlock() +} + +func AllFavicons() string { + favMu.RLock() + defer favMu.RUnlock() + return fmt.Sprintf("%v", favicons) +} diff --git a/cache/redir.go b/cache/redir.go index a8df263..a81a640 100644 --- a/cache/redir.go +++ b/cache/redir.go @@ -31,8 +31,8 @@ func AddRedir(og, redir string) { // ClearRedirs removes all redirects from the cache. func ClearRedirs() { redirMu.Lock() - defer redirMu.Unlock() redirUrls = make(map[string]string) + redirMu.Unlock() } // Redirect takes the provided URL and returns a redirected version, if a redirect diff --git a/config/config.go b/config/config.go index a424a52..f7b9f20 100644 --- a/config/config.go +++ b/config/config.go @@ -153,6 +153,7 @@ func Init() error { viper.SetDefault("a-general.downloads", "") viper.SetDefault("a-general.page_max_size", 2097152) viper.SetDefault("a-general.page_max_time", 10) + viper.SetDefault("a-general.emoji_favicons", false) viper.SetDefault("cache.max_size", 0) viper.SetDefault("cache.max_pages", 20) diff --git a/config/default.go b/config/default.go index a124017..ec226b3 100644 --- a/config/default.go +++ b/config/default.go @@ -12,6 +12,7 @@ var defaultConf = []byte(`# This is the default config file. # example.com:123 [a-general] +# Press Ctrl-H to access it home = "gemini://gemini.circumlunar.space" # What command to run to open a HTTP URL. Set to "default" to try to guess the browser, @@ -19,21 +20,36 @@ home = "gemini://gemini.circumlunar.space" # If a command is set, than the URL will be added (in quotes) to the end of the command. # A space will be prepended if necessary. http = "default" -search = "gemini://gus.guru/search" # Any URL that will accept a query string can be put here -color = true # Whether colors will be used in the terminal -bullets = true # Whether to replace list asterisks with unicode bullets + +# Any URL that will accept a query string can be put here +search = "gemini://gus.guru/search" + +# Whether colors will be used in the terminal +color = true + +# Whether to replace list asterisks with unicode bullets +bullets = true + # A number from 0 to 1, indicating what percentage of the terminal width the left margin should take up. left_margin = 0.15 -max_width = 100 # The max number of columns to wrap a page's text to. Preformatted blocks are not wrapped. + +# The max number of columns to wrap a page's text to. Preformatted blocks are not wrapped. +max_width = 100 + # 'downloads' is the path to a downloads folder. # An empty value means the code will find the default downloads folder for your system. # If the path does not exist it will be created. downloads = "" + # Max size for displayable content in bytes - after that size a download window pops up page_max_size = 2097152 # 2 MiB # Max time it takes to load a page in seconds - after that a download window pops up page_max_time = 10 +# Whether to replace tab numbers with emoji favicons, which are cached. +emoji_favicons = false + + # Options for page cache - which is only for text/gemini pages # Increase the cache size to speed up browsing at the expense of memory [cache] @@ -41,15 +57,16 @@ page_max_time = 10 max_size = 0 # Size in bytes max_pages = 30 # The maximum number of pages the cache will store + [theme] # This section is for changing the COLORS used in Amfora. -# These colors only apply if color is enabled above. -# Colors can be set using a W3C color name, or a hex value such as #ffffff". +# These colors only apply if 'color' is enabled above. +# Colors can be set using a W3C color name, or a hex value such as "#ffffff". # Note that not all colors will work on terminals that do not have truecolor support. # If you want to stick to the standard 16 or 256 colors, you can get # a list of those here: https://jonasjacek.github.io/colors/ -# Do NOT use the names from that site, just the hex codes. +# DO NOT use the names from that site, just the hex codes. # Definitions: # bg = background diff --git a/display/display.go b/display/display.go index 251cae0..20f1b70 100644 --- a/display/display.go +++ b/display/display.go @@ -486,24 +486,7 @@ func CloseTab() { } tabPages.SwitchToPage(strconv.Itoa(curTab)) // Go to previous page - // Rewrite the tab display - tabRow.Clear() - if viper.GetBool("a-general.color") { - for i := 0; i < NumTabs(); i++ { - fmt.Fprintf(tabRow, `["%d"][%s] %d [%s][""]|`, - i, - config.GetColorString("tab_num"), - i+1, - config.GetColorString("tab_divider"), - ) - } - } else { - for i := 0; i < NumTabs(); i++ { - fmt.Fprintf(tabRow, `["%d"] %d [""]|`, i, i+1) - } - } - tabRow.Highlight(strconv.Itoa(curTab)).ScrollToHighlight() - + rewriteTabRow() // Restore previous tab's state tabs[curTab].applyAll() @@ -550,8 +533,10 @@ func Reload() { return } - go cache.RemovePage(tabs[curTab].page.Url) + parsed, _ := url.Parse(tabs[curTab].page.Url) go func(t *tab) { + cache.RemovePage(tabs[curTab].page.Url) + cache.RemoveFavicon(parsed.Host) handleURL(t, t.page.Url) // goURL is not used bc history shouldn't be added to if t == tabs[curTab] { // Display the bottomBar state that handleURL set diff --git a/display/private.go b/display/private.go index 28ad862..443b691 100644 --- a/display/private.go +++ b/display/private.go @@ -1,16 +1,22 @@ package display import ( + "bytes" + "fmt" + "io" "net/url" "os/exec" + "strconv" "strings" "github.com/makeworld-the-better-one/amfora/cache" "github.com/makeworld-the-better-one/amfora/client" + "github.com/makeworld-the-better-one/amfora/config" "github.com/makeworld-the-better-one/amfora/renderer" "github.com/makeworld-the-better-one/amfora/structs" "github.com/makeworld-the-better-one/amfora/webbrowser" "github.com/makeworld-the-better-one/go-gemini" + "github.com/makeworld-the-better-one/go-isemoji" "github.com/spf13/viper" "gitlab.com/tslocum/cview" ) @@ -106,8 +112,16 @@ func setPage(t *tab, p *structs.Page) { // Make sure the page content is fitted to the terminal every time it's displayed reformatPage(p) - // Change page on screen + oldFav := t.page.Favicon + t.page = p + + go func() { + parsed, _ := url.Parse(p.Url) + handleFavicon(t, parsed.Host, oldFav) + }() + + // Change page on screen t.view.SetText(p.Content) t.view.Highlight("") // Turn off highlights, other funcs may restore if necessary t.view.ScrollToBeginning() @@ -144,6 +158,81 @@ func handleHTTP(u string, showInfo bool) { App.Draw() } +// handleFavicon handles getting and displaying a favicon. +// `old` is the previous favicon for the tab. +func handleFavicon(t *tab, host, old string) { + defer func() { + // Update display if needed + if t.page.Favicon != old && isValidTab(t) { + rewriteTabRow() + } + }() + + if !viper.GetBool("a-general.emoji_favicons") { + // Not enabled + return + } + if t.page.Favicon != "" { + return + } + if host == "" { + return + } + + fav := cache.GetFavicon(host) + if fav == cache.KnownNoFavicon { + // It's been cached that this host doesn't have a favicon + return + } + if fav != "" { + t.page.Favicon = fav + rewriteTabRow() + return + } + + // No favicon cached + res, err := client.Fetch("gemini://" + host + "/favicon.txt") + if err != nil { + if res != nil { + res.Body.Close() + } + cache.AddFavicon(host, cache.KnownNoFavicon) + return + } + defer res.Body.Close() + + if res.Status != 20 { + cache.AddFavicon(host, cache.KnownNoFavicon) + return + } + if !strings.HasPrefix(res.Meta, "text/plain") { + cache.AddFavicon(host, cache.KnownNoFavicon) + return + } + // It's a regular plain response + + buf := new(bytes.Buffer) + _, err = io.CopyN(buf, res.Body, 29+2+1) // 29 is the max emoji length, +2 for CRLF, +1 so that the right size will EOF + if err == nil { + // Content was too large + cache.AddFavicon(host, cache.KnownNoFavicon) + return + } else if err != io.EOF { + // Some network reading error + // No favicon is NOT known, could be a temporary error + return + } + // EOF, which is what we want. + emoji := strings.TrimRight(buf.String(), "\r\n") + if !isemoji.IsEmoji(emoji) { + cache.AddFavicon(host, cache.KnownNoFavicon) + return + } + // Valid favicon found + t.page.Favicon = emoji + cache.AddFavicon(host, emoji) +} + // goURL is like handleURL, but takes care of history and the bottomBar. // It should be preferred over handleURL in most cases. // It has no return values to be processed. @@ -336,3 +425,32 @@ func handleURL(t *tab, u string) (string, bool) { go dlChoice("That file could not be displayed. What would you like to do?", u, res) return ret("", false) } + +// rewriteTabRow clears the tabRow and writes all the tabs number/favicons into it. +func rewriteTabRow() { + tabRow.Clear() + if viper.GetBool("a-general.color") { + for i := 0; i < NumTabs(); i++ { + char := strconv.Itoa(i + 1) + if tabs[i].page.Favicon != "" { + char = tabs[i].page.Favicon + } + fmt.Fprintf(tabRow, `["%d"][%s] %s [%s][""]|`, + i, + config.GetColorString("tab_num"), + char, + config.GetColorString("tab_divider"), + ) + } + } else { + for i := 0; i < NumTabs(); i++ { + char := strconv.Itoa(i + 1) + if tabs[i].page.Favicon != "" { + char = tabs[i].page.Favicon + } + fmt.Fprintf(tabRow, `["%d"] %s [""]|`, i, char) + } + } + tabRow.Highlight(strconv.Itoa(curTab)).ScrollToHighlight() + App.Draw() +} diff --git a/go.mod b/go.mod index d983188..92ee038 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/gdamore/tcell v1.3.1-0.20200608133353-cb1e5d6fa606 github.com/makeworld-the-better-one/go-gemini v0.7.0 + github.com/makeworld-the-better-one/go-isemoji v1.0.0 github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20200710151429-125743e22b4f github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/mapstructure v1.3.1 // indirect @@ -16,10 +17,9 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.7.0 - github.com/stretchr/testify v1.6.0 + github.com/stretchr/testify v1.6.1 gitlab.com/tslocum/cview v1.4.8-0.20200713214710-cc7796c4ca44 golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1 // indirect golang.org/x/text v0.3.3 gopkg.in/ini.v1 v1.57.0 // indirect - gopkg.in/yaml.v3 v3.0.0-20200603094226-e3079894b1e8 // indirect ) diff --git a/go.sum b/go.sum index f9b1bca..f564142 100644 --- a/go.sum +++ b/go.sum @@ -126,6 +126,8 @@ github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzR github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/makeworld-the-better-one/go-gemini v0.7.0 h1:TCerE47eYHLXj6RQDjfd5HdGVbcVqpBC6OoPBlyY7q4= github.com/makeworld-the-better-one/go-gemini v0.7.0/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +github.com/makeworld-the-better-one/go-isemoji v1.0.0 h1:W3O4+qwtXeT8PUDzcQ1UjxiupQWgc/oJHpqwrllx3xM= +github.com/makeworld-the-better-one/go-isemoji v1.0.0/go.mod h1:FBjkPl9rr0G4vlZCc+Mr+QcnOfGCTbGWYW8/1sp06I0= github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20200710151429-125743e22b4f h1:YEUlTs5gb35UlBLTgqrub9axWTYB3d7/8TxrkJDZpRI= github.com/makeworld-the-better-one/progressbar/v3 v3.3.5-0.20200710151429-125743e22b4f/go.mod h1:X6sxWNi9PBgQybpR4fpXPVD5fm7svLqZTQ5DJuERIoM= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -203,8 +205,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.6.0 h1:jlIyCplCJFULU/01vCkhKuTyc3OorI3bJFuw6obfgho= -github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= @@ -345,8 +347,8 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200603094226-e3079894b1e8 h1:jL/vaozO53FMfZLySWM+4nulF3gQEC6q5jH90LPomDo= -gopkg.in/yaml.v3 v3.0.0-20200603094226-e3079894b1e8/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/renderer/page.go b/renderer/page.go index c1c2254..8ae5277 100644 --- a/renderer/page.go +++ b/renderer/page.go @@ -67,7 +67,6 @@ func MakePage(url string, res *gemini.Response, width, leftMargin int) (*structs _, err := io.CopyN(buf, res.Body, viper.GetInt64("a-general.page_max_size")+1) res.Body.Close() - rawText := buf.Bytes() if err == nil { // Content was larger than max size return nil, ErrTooLarge @@ -86,14 +85,14 @@ func MakePage(url string, res *gemini.Response, width, leftMargin int) (*structs // Convert content first var utfText string if isUTF8(params["charset"]) { - utfText = string(rawText) + utfText = buf.String() } else { encoding, err := ianaindex.MIME.Encoding(params["charset"]) if encoding == nil || err != nil { // Some encoding doesn't exist and wasn't caught in CanDisplay() return nil, errors.New("unsupported encoding") } - utfText, err = encoding.NewDecoder().String(string(rawText)) + utfText, err = encoding.NewDecoder().String(buf.String()) if err != nil { return nil, err } diff --git a/structs/structs.go b/structs/structs.go index a383adc..a98c479 100644 --- a/structs/structs.go +++ b/structs/structs.go @@ -29,6 +29,7 @@ type Page struct { Selected string // The current text or link selected SelectedID string // The cview region ID for the selected text/link Mode PageMode + Favicon string } // Size returns an approx. size of a Page in bytes.