1
0
mirror of https://github.com/makew0rld/amfora.git synced 2024-06-01 18:31:08 +00:00

Merge branch 'master' into commands

This commit is contained in:
mntn 2022-01-01 20:26:47 -05:00 committed by GitHub
commit 3b3d77447e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 463 additions and 178 deletions

View File

@ -7,10 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Syntax highlighting for preformatted text blocks with alt text (#252, #263, [wiki page](https://github.com/makeworld-the-better-one/amfora/wiki/Source-Code-Highlighting))
- [Client certificates](https://github.com/makeworld-the-better-one/amfora/wiki/Client-Certificates) can be restricted to certain paths of a host (#115)
- `header` config option in `[subscriptions]` to allow disabling the header text on the subscriptions page (#191)
- Selected link and scroll position stays for non-cached pages (#122)
- Keybinding to open URL with URL handler instead of configured proxy (#143)
- `include` theme key to import themes from an external file (#154)
- Support SOCKS5 proxying by setting `AMFORA_SOCKS5` environment variable (#155)
### Changed
- Center text automatically, removing `left_margin` from the config (#233)
- `max_width` defaults to 80 columns instead of 100 (#233)
- Tabs have the domain of the current page instead of numbers (#202)
### Fixed
- Modal can't be closed when opening non-gemini text URLs from the commandline (#283, #284)

View File

@ -54,7 +54,12 @@ func main() {
fmt.Fprintf(os.Stderr, "Config error: %v\n", err)
os.Exit(1)
}
client.Init()
err = client.Init()
if err != nil {
fmt.Fprintf(os.Stderr, "Client error: %v\n", err)
os.Exit(1)
}
err = subscriptions.Init()
if err != nil {

View File

@ -2,51 +2,128 @@
package client
import (
"errors"
"io/ioutil"
"net"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/makeworld-the-better-one/go-gemini"
gemsocks5 "github.com/makeworld-the-better-one/go-gemini-socks5"
"github.com/mitchellh/go-homedir"
"github.com/spf13/viper"
)
// Simple key for certCache map and others, instead of a full URL
// Only uses the part of the URL relevant to matching certs to a URL
type certMapKey struct {
host string
path string
}
var (
certCache = make(map[string][][]byte)
// [auth] section of config put into maps
confCerts = make(map[certMapKey]string)
confKeys = make(map[certMapKey]string)
// Cache the cert and key assigned to different URLs
certCache = make(map[certMapKey][][]byte)
certCacheMu = &sync.RWMutex{}
fetchClient *gemini.Client
)
func Init() {
func Init() error {
fetchClient = &gemini.Client{
ConnectTimeout: 10 * time.Second, // Default is 15
ReadTimeout: time.Duration(viper.GetInt("a-general.page_max_time")) * time.Second,
}
if socksHost := os.Getenv("AMFORA_SOCKS5"); socksHost != "" {
fetchClient.Proxy = gemsocks5.ProxyFunc(socksHost, nil)
}
// Populate config maps
certsViper := viper.Sub("auth.certs")
for _, certURL := range certsViper.AllKeys() {
// Normalize URL so that it can be matched no matter how it was written
// in the config
pu, _ := normalizeURL(FixUserURL(certURL))
if pu == nil {
//nolint:goerr113
return errors.New("[auth.certs]: couldn't normalize URL: " + certURL)
}
confCerts[certMapKey{pu.Host, pu.Path}] = certsViper.GetString(certURL)
}
keysViper := viper.Sub("auth.keys")
for _, keyURL := range keysViper.AllKeys() {
pu, _ := normalizeURL(FixUserURL(keyURL))
if pu == nil {
//nolint:goerr113
return errors.New("[auth.keys]: couldn't normalize URL: " + keyURL)
}
confKeys[certMapKey{pu.Host, pu.Path}] = keysViper.GetString(keyURL)
}
return nil
}
func clientCert(host string) ([]byte, []byte) {
// getCertPath returns the path of the cert from the config.
// It returns "" if no config value exists.
func getCertPath(host string, path string) string {
for k, v := range confCerts {
if k.host == host && (k.path == path || strings.HasPrefix(path, k.path)) {
// Either exact match to what's in config, or a subpath
return v
}
}
// No matches
return ""
}
// getKeyPath returns the path of the key from the config.
// It returns "" if no config value exists.
func getKeyPath(host string, path string) string {
for k, v := range confKeys {
if k.host == host && (k.path == path || strings.HasPrefix(path, k.path)) {
// Either exact match to what's in config, or a subpath
return v
}
}
// No matches
return ""
}
func clientCert(host string, path string) ([]byte, []byte) {
mkey := certMapKey{host, path}
certCacheMu.RLock()
pair, ok := certCache[host]
pair, ok := certCache[mkey]
certCacheMu.RUnlock()
if ok {
return pair[0], pair[1]
}
ogCertPath := getCertPath(host, path)
// Expand paths starting with ~/
certPath, err := homedir.Expand(viper.GetString("auth.certs." + host))
certPath, err := homedir.Expand(ogCertPath)
if err != nil {
certPath = viper.GetString("auth.certs." + host)
certPath = ogCertPath
}
keyPath, err := homedir.Expand(viper.GetString("auth.keys." + host))
ogKeyPath := getKeyPath(host, path)
keyPath, err := homedir.Expand(ogKeyPath)
if err != nil {
keyPath = viper.GetString("auth.keys." + host)
keyPath = ogKeyPath
}
if certPath == "" && keyPath == "" {
certCacheMu.Lock()
certCache[host] = [][]byte{nil, nil}
certCache[mkey] = [][]byte{nil, nil}
certCacheMu.Unlock()
return nil, nil
}
@ -54,33 +131,33 @@ func clientCert(host string) ([]byte, []byte) {
cert, err := ioutil.ReadFile(certPath)
if err != nil {
certCacheMu.Lock()
certCache[host] = [][]byte{nil, nil}
certCache[mkey] = [][]byte{nil, nil}
certCacheMu.Unlock()
return nil, nil
}
key, err := ioutil.ReadFile(keyPath)
if err != nil {
certCacheMu.Lock()
certCache[host] = [][]byte{nil, nil}
certCache[mkey] = [][]byte{nil, nil}
certCacheMu.Unlock()
return nil, nil
}
certCacheMu.Lock()
certCache[host] = [][]byte{cert, key}
certCache[mkey] = [][]byte{cert, key}
certCacheMu.Unlock()
return cert, key
}
// HasClientCert returns whether or not a client certificate exists for a host.
func HasClientCert(host string) bool {
cert, _ := clientCert(host)
// HasClientCert returns whether or not a client certificate exists for a host and path.
func HasClientCert(host string, path string) bool {
cert, _ := clientCert(host, path)
return cert != nil
}
func fetch(u string, c *gemini.Client) (*gemini.Response, error) {
parsed, _ := url.Parse(u)
cert, key := clientCert(parsed.Host)
cert, key := clientCert(parsed.Host, parsed.Path)
var res *gemini.Response
var err error
@ -109,7 +186,7 @@ func Fetch(u string) (*gemini.Response, error) {
func fetchWithProxy(proxyHostname, proxyPort, u string, c *gemini.Client) (*gemini.Response, error) {
parsed, _ := url.Parse(u)
cert, key := clientCert(parsed.Host)
cert, key := clientCert(parsed.Host, parsed.Path)
var res *gemini.Response
var err error

102
client/url.go Normal file
View File

@ -0,0 +1,102 @@
package client
// Functions that transform and normalize URLs
// Originally used to be in display/util.go
// Moved here for #115, so URLs in the [auth] config section could be normalized
import (
"net/url"
"strings"
"github.com/makeworld-the-better-one/go-gemini"
"golang.org/x/text/unicode/norm"
)
// See doc for NormalizeURL
func normalizeURL(u string) (*url.URL, string) {
u = norm.NFC.String(u)
tmp, err := gemini.GetPunycodeURL(u)
if err != nil {
return nil, u
}
u = tmp
parsed, _ := url.Parse(u)
if parsed.Scheme == "" {
// Always add scheme
parsed.Scheme = "gemini"
} else if parsed.Scheme != "gemini" {
// Not a gemini URL, nothing to do
return nil, u
}
parsed.User = nil // No passwords in Gemini
parsed.Fragment = "" // No fragments either
if parsed.Port() == "1965" {
// Always remove default port
hostname := parsed.Hostname()
if strings.Contains(hostname, ":") {
parsed.Host = "[" + parsed.Hostname() + "]"
} else {
parsed.Host = parsed.Hostname()
}
}
// Add slash to the end of a URL with just a domain
// gemini://example.com -> gemini://example.com/
if parsed.Path == "" {
parsed.Path = "/"
} else {
// Decode and re-encode path
// This removes needless encoding, like that of ASCII chars
// And encodes anything that wasn't but should've been
parsed.RawPath = strings.ReplaceAll(url.PathEscape(parsed.Path), "%2F", "/")
}
// Do the same to the query string
un, err := gemini.QueryUnescape(parsed.RawQuery)
if err == nil {
parsed.RawQuery = gemini.QueryEscape(un)
}
return parsed, ""
}
// NormalizeURL attempts to make URLs that are different strings
// but point to the same place all look the same.
//
// Example: gemini://gus.guru:1965/ and //gus.guru/.
// This function will take both output the same URL each time.
//
// It will also percent-encode invalid characters, and decode chars
// that don't need to be encoded. It will also apply Unicode NFC
// normalization.
//
// The string passed must already be confirmed to be a URL.
// Detection of a search string vs. a URL must happen elsewhere.
//
// It only works with absolute URLs.
func NormalizeURL(u string) string {
pu, s := normalizeURL(u)
if pu != nil {
// Could be normalized, return it
return pu.String()
}
// Return the best URL available up to that point
return s
}
// FixUserURL will take a user-typed URL and add a gemini scheme to it if
// necessary. It is not the same as normalizeURL, and that func should still
// be used, afterward.
//
// For example "example.com" will become "gemini://example.com", but
// "//example.com" will be left untouched.
func FixUserURL(u string) string {
if !strings.HasPrefix(u, "//") && !strings.HasPrefix(u, "gemini://") && !strings.Contains(u, "://") {
// Assume it's a Gemini URL
u = "gemini://" + u
}
return u
}

View File

@ -1,5 +1,5 @@
//nolint: lll
package display
package client
import (
"testing"
@ -36,7 +36,7 @@ var normalizeURLTests = []struct {
func TestNormalizeURL(t *testing.T) {
for _, tt := range normalizeURLTests {
actual := normalizeURL(tt.u)
actual := NormalizeURL(tt.u)
if actual != tt.expected {
t.Errorf("normalizeURL(%s): expected %s, actual %s", tt.u, tt.expected, actual)
}

View File

@ -285,6 +285,7 @@ func Init() error {
viper.SetDefault("keybindings.bind_beginning", []string{"Home", "g"})
viper.SetDefault("keybindings.bind_end", []string{"End", "G"})
viper.SetDefault("keybindings.shift_numbers", "")
viper.SetDefault("keybindings.bind_url_handler_open", "Ctrl-U")
viper.SetDefault("url-handlers.other", "default")
viper.SetDefault("cache.max_size", 0)
viper.SetDefault("cache.max_pages", 20)
@ -293,6 +294,7 @@ func Init() error {
viper.SetDefault("subscriptions.update_interval", 1800)
viper.SetDefault("subscriptions.workers", 3)
viper.SetDefault("subscriptions.entries_per_page", 20)
viper.SetDefault("subscriptions.header", true)
viper.SetConfigFile(configPath)
viper.SetConfigType("toml")
@ -375,29 +377,56 @@ func Init() error {
cache.SetMaxPages(viper.GetInt("cache.max_pages"))
cache.SetTimeout(viper.GetInt("cache.timeout"))
setColor := func(k string, colorStr string) error {
colorStr = strings.ToLower(colorStr)
var color tcell.Color
if colorStr == "default" {
if strings.HasSuffix(k, "bg") {
color = tcell.ColorDefault
} else {
return fmt.Errorf(`"default" is only valid for a background color (color ending in "bg"), not "%s"`, k)
}
} else {
color = tcell.GetColor(colorStr)
if color == tcell.ColorDefault {
return fmt.Errorf(`invalid color format for "%s": %s`, k, colorStr)
}
}
SetColor(k, color)
return nil
}
// Setup theme
configTheme := viper.Sub("theme")
if configTheme != nil {
// Include key comes first
if incPath := configTheme.GetString("include"); incPath != "" {
incViper := viper.New()
incViper.SetConfigFile(incPath)
incViper.SetConfigType("toml")
err = incViper.ReadInConfig()
if err != nil {
return err
}
for k2, v2 := range incViper.AllSettings() {
colorStr, ok := v2.(string)
if !ok {
return fmt.Errorf(`include: value for "%s" is not a string: %v`, k2, v2)
}
if err := setColor(k2, colorStr); err != nil {
return err
}
}
}
for k, v := range configTheme.AllSettings() {
colorStr, ok := v.(string)
if !ok {
return fmt.Errorf(`value for "%s" is not a string: %v`, k, v)
}
colorStr = strings.ToLower(colorStr)
var color tcell.Color
if colorStr == "default" {
if strings.HasSuffix(k, "bg") {
color = tcell.ColorDefault
} else {
return fmt.Errorf(`"default" is only valid for a background color (color ending in "bg"), not "%s"`, k)
}
} else {
color = tcell.GetColor(colorStr)
if color == tcell.ColorDefault {
return fmt.Errorf(`invalid color format for "%s": %s`, k, colorStr)
}
if err := setColor(k, colorStr); err != nil {
return err
}
SetColor(k, color)
}
}
if viper.GetBool("a-general.color") {

View File

@ -99,13 +99,17 @@ underline = true
[auth.certs]
# Client certificates
# Set domain name equal to path to client cert
# "example.com" = 'mycert.crt'
# Set URL equal to path to client cert file
#
# "example.com" = 'mycert.crt' # Cert is used for all paths on this domain
# "example.com/dir/"= 'mycert.crt' # Cert is used for /dir/ and everything below only
#
# See the comment at the beginning of this file for examples of all valid types of
# URLs, ports and schemes can be used too
[auth.keys]
# Client certificate keys
# Set domain name equal to path to key for the client cert above
# "example.com" = 'mycert.key'
# Same as [auth.certs] but the path is to the client key file.
[commands]
@ -230,6 +234,7 @@ underline = true
# bind_copy_target_url
# bind_beginning: moving to beginning of page (top left)
# bind_end: same but the for the end (bottom left)
# bind_url_handler_open: Open highlighted URL with URL handler (#143)
[url-handlers]
# Allows setting the commands to run for various URL schemes.
@ -375,6 +380,9 @@ workers = 3
# The number of subscription updates displayed per page.
entries_per_page = 20
# Set to false to remove the explanatory text from the top of the subscription page
header = true
[theme]
# This section is for changing the COLORS used in Amfora.
@ -414,6 +422,15 @@ entries_per_page = 20
# bottombar_bg
# scrollbar: The scrollbar that appears on the right for long pages
# You can also set an 'include' key to process another TOML file that contains theme keys.
# Example:
# include = "my/path/to/special-theme.toml"
#
# Any other theme keys will override this external file.
# You can use this special key to switch between themes easily.
# Download other themes here: https://github.com/makeworld-the-better-one/amfora/tree/master/contrib/themes
# hdg_1
# hdg_2
# hdg_3

View File

@ -83,6 +83,7 @@ const (
CmdCopyTargetURL
CmdBeginning
CmdEnd
CmdURLHandlerOpen // See #143
)
type keyBinding struct {
@ -220,6 +221,7 @@ func KeyInit() {
CmdCopyTargetURL: "keybindings.bind_copy_target_url",
CmdBeginning: "keybindings.bind_beginning",
CmdEnd: "keybindings.bind_end",
CmdURLHandlerOpen: "keybindings.bind_url_handler_open",
CmdCommand1: "keybindings.bind_command1",
CmdCommand2: "keybindings.bind_command2",
CmdCommand3: "keybindings.bind_command3",

View File

@ -1,4 +1,4 @@
[theme]
#[theme]
# Only the 256 xterm colors are used, so truecolor support is not needed

View File

@ -1,4 +1,4 @@
[theme]
#[theme]
# atelier forest light

View File

@ -1,4 +1,4 @@
[theme]
#[theme]
# atelier forest

View File

@ -1,4 +1,4 @@
[theme]
#[theme]
bg = "#282a36"
tab_num = "#bd93f9"
tab_divider = "#f8f8f2"

View File

@ -1,4 +1,4 @@
[theme]
#[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".

View File

@ -1,4 +1,4 @@
[theme]
#[theme]
bg = "#ffffff"
tab_num = "#000000"
tab_divider = "#000000"

View File

@ -1,4 +1,4 @@
[theme]
#[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".

View File

@ -1,4 +1,4 @@
[theme]
#[theme]
# Gruvbox Dark theme

View File

@ -1,4 +1,4 @@
[theme]
#[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".

View File

@ -1,4 +1,4 @@
[theme]
#[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".

View File

@ -1,7 +1,7 @@
# Atom One Dark theme ported to Amfora
# by Serge Tymoshenko <serge@tymo.name>
[theme]
#[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".

View File

@ -1,4 +1,4 @@
[theme]
#[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".

View File

@ -1,4 +1,4 @@
[theme]
#[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".

View File

@ -1,4 +1,4 @@
[theme]
#[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".

View File

@ -1,4 +1,4 @@
[theme]
#[theme]
# Tokyo Night

View File

@ -96,13 +96,17 @@ underline = true
[auth.certs]
# Client certificates
# Set domain name equal to path to client cert
# "example.com" = 'mycert.crt'
# Set URL equal to path to client cert file
#
# "example.com" = 'mycert.crt' # Cert is used for all paths on this domain
# "example.com/dir/"= 'mycert.crt' # Cert is used for /dir/ and everything below only
#
# See the comment at the beginning of this file for examples of all valid types of
# URLs, ports and schemes can be used too
[auth.keys]
# Client certificate keys
# Set domain name equal to path to key for the client cert above
# "example.com" = 'mycert.key'
# Same as [auth.certs] but the path is to the client key file.
[commands]
@ -227,6 +231,7 @@ underline = true
# bind_copy_target_url
# bind_beginning: moving to beginning of page (top left)
# bind_end: same but the for the end (bottom left)
# bind_url_handler_open: Open highlighted URL with URL handler (#143)
[url-handlers]
# Allows setting the commands to run for various URL schemes.
@ -372,6 +377,9 @@ workers = 3
# The number of subscription updates displayed per page.
entries_per_page = 20
# Set to false to remove the explanatory text from the top of the subscription page
header = true
[theme]
# This section is for changing the COLORS used in Amfora.
@ -411,6 +419,15 @@ entries_per_page = 20
# bottombar_bg
# scrollbar: The scrollbar that appears on the right for long pages
# You can also set an 'include' key to process another TOML file that contains theme keys.
# Example:
# include = "my/path/to/special-theme.toml"
#
# Any other theme keys will override this external file.
# You can use this special key to switch between themes easily.
# Download other themes here: https://github.com/makeworld-the-better-one/amfora/tree/master/contrib/themes
# hdg_1
# hdg_2
# hdg_3

View File

@ -11,6 +11,7 @@ import (
"code.rocketnine.space/tslocum/cview"
"github.com/gdamore/tcell/v2"
"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"
@ -87,7 +88,7 @@ func Init(version, commit, builtBy string) {
// Overwrite all tabs with a new, differently sized, left margin
browser.AddTab(
strconv.Itoa(i),
makeTabLabel(strconv.Itoa(i+1)),
tabs[i].label(),
makeContentLayout(tabs[i].view, leftMargin()),
)
if tabs[i] == t {
@ -228,12 +229,12 @@ func Init(version, commit, builtBy string) {
u := viper.GetString("a-general.search") + "?" + gemini.QueryEscape(query)
// Don't use the cached version of the search
cache.RemovePage(normalizeURL(u))
cache.RemovePage(client.NormalizeURL(u))
URL(u)
} else {
// Full URL
// Don't use cached version for manually entered URL
cache.RemovePage(normalizeURL(fixUserURL(query)))
cache.RemovePage(client.NormalizeURL(client.FixUserURL(query)))
URL(query)
}
return
@ -437,7 +438,7 @@ func NewTabWithURL(url string) {
browser.AddTab(
strconv.Itoa(curTab),
makeTabLabel(strconv.Itoa(curTab+1)),
tabs[curTab].label(),
makeContentLayout(tabs[curTab].view, leftMargin()),
)
browser.SetCurrentTab(strconv.Itoa(curTab))
@ -555,7 +556,7 @@ func URL(u string) {
if strings.HasPrefix(u, "about:") {
go goURL(t, u)
} else {
go goURL(t, fixUserURL(u))
go goURL(t, client.FixUserURL(u))
}
}

View File

@ -216,6 +216,8 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) {
}
t.mode = tabModeDone
t.preferURLHandler = false
go func(p *structs.Page) {
if b && t.hasContent() && !t.isAnAboutPage() && viper.GetBool("subscriptions.popup") {
// The current page might be an untracked feed, and the user wants
@ -241,7 +243,7 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) {
return ret(handleAbout(t, u))
}
u = normalizeURL(u)
u = client.NormalizeURL(u)
u = cache.Redirect(u)
parsed, err := url.Parse(u)
@ -261,7 +263,7 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) {
}
if strings.HasPrefix(u, "http") {
if proxy == "" || proxy == "off" {
if proxy == "" || proxy == "off" || t.preferURLHandler {
// No proxy available
handleHTTP(u, true)
return ret("", false)
@ -280,7 +282,7 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) {
if !strings.HasPrefix(u, "http") && !strings.HasPrefix(u, "gemini") && !strings.HasPrefix(u, "file") {
// Not a Gemini URL
if proxy == "" || proxy == "off" {
if proxy == "" || proxy == "off" || t.preferURLHandler {
// No proxy available
handleOther(u)
return ret("", false)
@ -376,7 +378,7 @@ func handleURL(t *tab, u string, numRedirects int) (string, bool) {
page.TermWidth = termW
if !client.HasClientCert(parsed.Host) {
if !client.HasClientCert(parsed.Host, parsed.Path) {
// Don't cache pages with client certs
go cache.AddPage(page)
}

View File

@ -33,6 +33,7 @@ var helpCells = strings.TrimSpace(
"Enter, Tab\tOn a page this will start link highlighting.\n" +
"\tPress Tab and Shift-Tab to pick different links.\n" +
"\tPress Enter again to go to one, or Esc to stop.\n" +
"%s\tOpen the highlighted URL with a URL handler instead of the configured proxy\n" +
"%s\tGo to a specific tab. (Default: Shift-NUMBER)\n" +
"%s\tGo to the last tab.\n" +
"%s\tPrevious tab\n" +
@ -103,6 +104,7 @@ func helpInit() {
config.GetKeyBinding(config.CmdEdit),
config.GetKeyBinding(config.CmdCopyPageURL),
config.GetKeyBinding(config.CmdCopyTargetURL),
config.GetKeyBinding(config.CmdURLHandlerOpen),
tabKeys,
config.GetKeyBinding(config.CmdTab0),
config.GetKeyBinding(config.CmdPrevTab),

View File

@ -3,6 +3,18 @@ package display
// applyHist is a history.go internal function, to load a URL in the history.
func applyHist(t *tab) {
handleURL(t, t.history.urls[t.history.pos], 0) // Load that position in history
// Set page's scroll and link info from history cache, in case it didn't have it in the page already
// Like for non-cached pages like about: pages
// This fixes #122
pg := t.history.pageCache[t.history.pos]
p := t.page
p.Row = pg.row
p.Column = pg.column
p.Selected = pg.selected
p.SelectedID = pg.selectedID
p.Mode = pg.mode
t.applyAll()
}
@ -11,6 +23,10 @@ func histForward(t *tab) {
// Already on the most recent URL in the history
return
}
// Update page cache in history for #122
t.historyCachePage()
t.history.pos++
go applyHist(t)
}
@ -20,6 +36,10 @@ func histBack(t *tab) {
// First tab in history
return
}
// Update page cache in history for #122
t.historyCachePage()
t.history.pos--
go applyHist(t)
}

View File

@ -118,7 +118,7 @@ func setPage(t *tab, p *structs.Page) {
tabNum := tabNumber(t)
browser.AddTab(
strconv.Itoa(tabNum),
makeTabLabel(strconv.Itoa(tabNum+1)),
t.label(),
makeContentLayout(t.view, leftMargin()),
)
App.Draw()
@ -137,6 +137,9 @@ func setPage(t *tab, p *structs.Page) {
//
// It should be called in a goroutine.
func goURL(t *tab, u string) {
// Update page cache in history for #122
t.historyCachePage()
final, displayed := handleURL(t, u, 0)
if displayed {
t.addToHistory(final)

View File

@ -97,9 +97,12 @@ func Subscriptions(t *tab, u string) string {
} else {
// Render page
rawPage += "You can use Ctrl-X to subscribe to a page, or to an Atom/RSS/JSON feed. See the online wiki for more.\n" +
"If you just opened Amfora then updates may appear incrementally. Reload the page to see them.\n\n" +
"=> about:manage-subscriptions Manage subscriptions\n\n"
if viper.GetBool("subscriptions.header") {
rawPage += "You can use Ctrl-X to subscribe to a page, or to an Atom/RSS/JSON feed." +
"See the online wiki for more.\n" +
"If you just opened Amfora then updates may appear incrementally. Reload the page to see them.\n\n"
}
rawPage += "=> about:manage-subscriptions Manage subscriptions\n\n"
// curDay represents what day of posts the loop is on.
// It only goes backwards in time.

View File

@ -3,6 +3,7 @@ package display
import (
"fmt"
"net/url"
"path"
"strconv"
"strings"
@ -20,19 +21,31 @@ const (
tabModeLoading
)
// tabHistoryPageCache is fields from the Page struct, cached here to solve #122
// See structs/structs.go for an explanation of the fields.
type tabHistoryPageCache struct {
row int
column int
selected string
selectedID string
mode structs.PageMode
}
type tabHistory struct {
urls []string
pos int // Position: where in the list of URLs we are
urls []string
pos int // Position: where in the list of URLs we are
pageCache []*tabHistoryPageCache
}
// tab hold the information needed for each browser tab.
type tab struct {
page *structs.Page
view *cview.TextView
history *tabHistory
mode tabMode
barLabel string // The bottomBar label for the tab
barText string // The bottomBar text for the tab
page *structs.Page
view *cview.TextView
history *tabHistory
mode tabMode
barLabel string // The bottomBar label for the tab
barText string // The bottomBar text for the tab
preferURLHandler bool // For #143, use URL handler over proxy
}
// makeNewTab initializes an tab struct with no content.
@ -86,6 +99,7 @@ func makeNewTab() *tab {
linkN, _ := strconv.Atoi(currentSelection[0])
tabs[tab].page.Selected = tabs[tab].page.Links[linkN]
tabs[tab].page.SelectedID = currentSelection[0]
tabs[tab].preferURLHandler = false // Reset in case
go followLink(tabs[tab], tabs[tab].page.URL, tabs[tab].page.Links[linkN])
return
}
@ -204,11 +218,24 @@ func makeNewTab() *tab {
return nil
}
return nil
case config.CmdURLHandlerOpen:
currentSelection := t.view.GetHighlights()
t.preferURLHandler = true
// Copied code from when enter key is pressed
if len(currentSelection) > 0 {
bottomBar.SetLabel("")
linkN, _ := strconv.Atoi(currentSelection[0])
t.page.Selected = t.page.Links[linkN]
t.page.SelectedID = currentSelection[0]
go followLink(&t, t.page.URL, t.page.Links[linkN])
}
return nil
}
// Number key: 1-9, 0, LINK1-LINK10
if cmd >= config.CmdLink1 && cmd <= config.CmdLink0 {
if int(cmd) <= len(t.page.Links) {
// It's a valid link number
t.preferURLHandler = false // Reset in case
go followLink(&t, t.page.URL, t.page.Links[cmd-1])
return nil
}
@ -313,6 +340,21 @@ func makeNewTab() *tab {
return &t
}
// historyCachePage caches certain info about the current page in the tab's history,
// see #122 for details.
func (t *tab) historyCachePage() {
if t.page == nil || t.page.URL == "" || t.history.pageCache == nil || len(t.history.pageCache) == 0 {
return
}
t.history.pageCache[t.history.pos] = &tabHistoryPageCache{
row: t.page.Row,
column: t.page.Column,
selected: t.page.Selected,
selectedID: t.page.SelectedID,
mode: t.page.Mode,
}
}
// addToHistory adds the given URL to history.
// It assumes the URL is currently being loaded and displayed on the page.
func (t *tab) addToHistory(u string) {
@ -320,9 +362,15 @@ func (t *tab) addToHistory(u string) {
// We're somewhere in the middle of the history instead, with URLs ahead and behind.
// The URLs ahead need to be removed so this new URL is the most recent item in the history
t.history.urls = t.history.urls[:t.history.pos+1]
// Same for page cache
t.history.pageCache = t.history.pageCache[:t.history.pos+1]
}
t.history.urls = append(t.history.urls, u)
t.history.pos++
// Cache page info for #122
t.history.pageCache = append(t.history.pageCache, &tabHistoryPageCache{}) // Add new spot
t.historyCachePage() // Fill it with data
}
// pageUp scrolls up 75% of the height of the terminal, like Bombadillo.
@ -380,7 +428,7 @@ func (t *tab) applyHorizontalScroll() {
// Scrolled to the right far enough that no left margin is needed
browser.AddTab(
strconv.Itoa(i),
makeTabLabel(strconv.Itoa(i+1)),
t.label(),
makeContentLayout(t.view, 0),
)
t.view.ScrollTo(t.page.Row, t.page.Column-leftMargin())
@ -388,7 +436,7 @@ func (t *tab) applyHorizontalScroll() {
// Left margin is still needed, but is not necessarily at the right size by default
browser.AddTab(
strconv.Itoa(i),
makeTabLabel(strconv.Itoa(i+1)),
t.label(),
makeContentLayout(t.view, leftMargin()-t.page.Column),
)
}
@ -499,3 +547,35 @@ func (t *tab) highlightedURL() string {
}
return ""
}
// label returns the label to use for the tab name
func (t *tab) label() string {
tn := tabNumber(t)
if tn < 0 {
// Invalid tab, shouldn't happen
return ""
}
// Increment so there's no tab 0 in the label
tn++
if t.page.URL == "" || t.page.URL == "about:newtab" {
// Just use tab number
// Spaces around to keep original Amfora look
return fmt.Sprintf(" %d ", tn)
}
if strings.HasPrefix(t.page.URL, "about:") {
// Don't look for domain, put the whole URL except query strings
return strings.SplitN(t.page.URL, "?", 2)[0]
}
if strings.HasPrefix(t.page.URL, "file://") {
// File URL, use file or folder as tab name
return path.Base(t.page.URL[7:])
}
// Otherwise, it's a Gemini URL
pu, err := url.Parse(t.page.URL)
if err != nil {
return fmt.Sprintf(" %d ", tn)
}
return pu.Host
}

View File

@ -6,9 +6,7 @@ import (
"strings"
"code.rocketnine.space/tslocum/cview"
"github.com/makeworld-the-better-one/go-gemini"
"github.com/spf13/viper"
"golang.org/x/text/unicode/norm"
)
// This file contains funcs that are small, self-contained utilities.
@ -34,12 +32,6 @@ func makeContentLayout(tv *cview.TextView, leftMargin int) *cview.Flex {
return vert
}
// makeTabLabel takes a string and adds spacing to it, making it
// suitable for display as a tab label.
func makeTabLabel(s string) string {
return " " + s + " "
}
// tabNumber gets the index of the tab in the tabs slice. It returns -1
// if the tab is not in that slice.
func tabNumber(t *tab) int {
@ -106,81 +98,3 @@ func resolveRelLink(t *tab, prev, next string) (string, error) {
}
return prevParsed.ResolveReference(nextParsed).String(), nil
}
// normalizeURL attempts to make URLs that are different strings
// but point to the same place all look the same.
//
// Example: gemini://gus.guru:1965/ and //gus.guru/.
// This function will take both output the same URL each time.
//
// It will also percent-encode invalid characters, and decode chars
// that don't need to be encoded. It will also apply Unicode NFC
// normalization.
//
// The string passed must already be confirmed to be a URL.
// Detection of a search string vs. a URL must happen elsewhere.
//
// It only works with absolute URLs.
func normalizeURL(u string) string {
u = norm.NFC.String(u)
tmp, err := gemini.GetPunycodeURL(u)
if err != nil {
return u
}
u = tmp
parsed, _ := url.Parse(u)
if parsed.Scheme == "" {
// Always add scheme
parsed.Scheme = "gemini"
} else if parsed.Scheme != "gemini" {
// Not a gemini URL, nothing to do
return u
}
parsed.User = nil // No passwords in Gemini
parsed.Fragment = "" // No fragments either
if parsed.Port() == "1965" {
// Always remove default port
hostname := parsed.Hostname()
if strings.Contains(hostname, ":") {
parsed.Host = "[" + parsed.Hostname() + "]"
} else {
parsed.Host = parsed.Hostname()
}
}
// Add slash to the end of a URL with just a domain
// gemini://example.com -> gemini://example.com/
if parsed.Path == "" {
parsed.Path = "/"
} else {
// Decode and re-encode path
// This removes needless encoding, like that of ASCII chars
// And encodes anything that wasn't but should've been
parsed.RawPath = strings.ReplaceAll(url.PathEscape(parsed.Path), "%2F", "/")
}
// Do the same to the query string
un, err := gemini.QueryUnescape(parsed.RawQuery)
if err == nil {
parsed.RawQuery = gemini.QueryEscape(un)
}
return parsed.String()
}
// fixUserURL will take a user-typed URL and add a gemini scheme to it if
// necessary. It is not the same as normalizeURL, and that func should still
// be used, afterward.
//
// For example "example.com" will become "gemini://example.com", but
// "//example.com" will be left untouched.
func fixUserURL(u string) string {
if !strings.HasPrefix(u, "//") && !strings.HasPrefix(u, "gemini://") && !strings.Contains(u, "://") {
// Assume it's a Gemini URL
u = "gemini://" + u
}
return u
}

3
go.mod
View File

@ -10,7 +10,8 @@ require (
github.com/fsnotify/fsnotify v1.4.9 // indirect
github.com/gdamore/tcell/v2 v2.3.3
github.com/google/go-cmp v0.5.0 // indirect
github.com/makeworld-the-better-one/go-gemini v0.12.1
github.com/makeworld-the-better-one/go-gemini v0.13.0
github.com/makeworld-the-better-one/go-gemini-socks5 v1.0.0
github.com/mitchellh/go-homedir v1.1.0
github.com/mitchellh/mapstructure v1.3.1 // indirect
github.com/mmcdole/gofeed v1.1.2

11
go.sum
View File

@ -151,12 +151,13 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/makeworld-the-better-one/go-gemini v0.12.1 h1:cWHvCHL31Caq3Rm9elCFFoQeyrn92Kv7KummsVxCOFg=
github.com/makeworld-the-better-one/go-gemini v0.12.1/go.mod h1:F+3x+R1xeYK90jMtBq+U+8Sh64r2dHleDZ/en3YgSmg=
github.com/makeworld-the-better-one/go-gemini v0.13.0 h1:CwUKLldUlZwWolG1/xKkYVgerCzlxwi0OFOh216GTHk=
github.com/makeworld-the-better-one/go-gemini v0.13.0/go.mod h1:F+3x+R1xeYK90jMtBq+U+8Sh64r2dHleDZ/en3YgSmg=
github.com/makeworld-the-better-one/go-gemini-socks5 v1.0.0 h1:D2o1rIfP/KOxcL3m3rzo4cfWNqfcGaMIhnU0keJc1+o=
github.com/makeworld-the-better-one/go-gemini-socks5 v1.0.0/go.mod h1:mfPK9BfBAAyLKuxPEbZi8mgrGmVlzMKVTGElVspuVR8=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
@ -306,8 +307,9 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201216054612-986b41b23924 h1:QsnDpLLOKwHBBDa8nDws4DYNc/ryVW2vCpxCs09d4PY=
golang.org/x/net v0.0.0-20201216054612-986b41b23924/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f h1:hEYJvxw1lSnWIl8X9ofsYMklzaDs90JI2az5YMd4fPM=
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -337,6 +339,7 @@ golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210223095934-7937bea0104d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210309040221-94ec62e08169/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea h1:+WiDlPBBaO+h9vPNZi8uJ3k4BkKQB7Iow3aqwHVA5hI=
golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=

View File

@ -9,11 +9,11 @@ import (
"os"
)
var logger *log.Logger
var Logger *log.Logger
func GetLogger() (*log.Logger, error) {
if logger != nil {
return logger, nil
if Logger != nil {
return Logger, nil
}
var writer io.Writer
@ -30,15 +30,15 @@ func GetLogger() (*log.Logger, error) {
writer = ioutil.Discard
}
logger = log.New(writer, "", log.LstdFlags)
Logger = log.New(writer, "", log.LstdFlags)
if !debugModeEnabled {
// Clear all flags to skip log output formatting step to increase
// performance somewhat if we're not logging anything
logger.SetFlags(0)
Logger.SetFlags(0)
}
logger.Println("Started logger")
Logger.Println("Started logger")
return logger, nil
return Logger, nil
}