#!/bin/sh
version="0.25.1"
usage() {
echo "astro v$version: Browse the gemini web on the terminal."
echo ""
echo "Usage: astro [URL]|[OPTION]"
echo ""
echo "Options:"
echo " -h, --help show this help"
echo " -v, --version show version info"
echo ""
echo "Configuration:"
echo "You can setup a config file at ~/.config/astro/astro.conf to configure *astro* the way you like."
echo ""
echo "Commands:"
echo "These are the default keybindings to use while running astro:"
echo ""
echo " q quit"
echo " g go to a link"
echo " r reload current page"
echo " b go back one page"
echo " u jump one path segment up"
echo " o open an address"
echo " s save current page"
echo " H go to homepage"
echo " m add bookmark"
echo " M go to a bookmark"
echo " K remove bookmark for current url"
echo ""
echo "Examples:"
echo " astro Start browsing the default webpage"
echo " astro url Start browsing url"
echo " astro --help Show help"
echo ""
echo "Report bugs to: bleemayer@gmail.com"
echo "Home page: "
echo "General help: "
}
version() {
echo "astro $version"
echo ""
echo "Copyright (C) 2021-2023 Brian Mayer."
echo "License MIT: MIT License "
echo "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,"
echo "EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF"
echo "MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT."
echo ""
echo "Written by Brian Lee Mayer."
}
debug() {
[ "$debug" ] && echo "DEBUG: $*" >&2 && sleep "$debug"
}
# Parse arguments
debug "parsing args"
while [ "$1" ]
do
case $1 in
"-v")
debug=1
;;
"-h" | "--help")
usage
exit 0
;;
"--version")
version
exit 0
;;
"-f"|"--file")
shift
file="$1"
;;
"")
break
;;
*)
url="$1"
;;
esac
shift
done
# Save terminal
tput smcup
# Configuration
confighome=${XDG_CONFIG_HOME:-$HOME/.config}
mkdir -p "$confighome/astro"
configfile="$confighome/astro/astro.conf"
bookmarkfile="$confighome/astro/bookmarks"
certdir="$confighome/astro/certs"
mkdir -p "$certdir"
cachedir="${XDG_CACHE_HOME:-$HOME/.cache}/astro"
# Default values
# user config
margin=8
homepage="geminiprotocol.net/"
# keybindings
openkey='o'
openlocalkey='O'
gokey='g'
refreshkey='r'
backkey='b'
quitkey='q'
markkey='b'
gomarkkey='M'
delmarkkey='K'
goupkey='u'
homekey='H'
# styles
sty_header1='\033[35;7;1m'
sty_header2='\033[35;4;1m'
sty_header3='\033[35;4m'
sty_quote='\033[2;3m '
sty_linkb='\033[35m'
sty_linkt=' => \033[36;3m '
sty_listb='\033[35;1m •'
sty_listt='\033[0m '
if [ ! -s "$configfile" ]
then
# Default values
cat <<- EOF > "$configfile"
# where to store temp files
cachedir="$cachedir"
# user config
margin=8
homepage="gemini.circumlunar.space/"
# keybindings
openkey='o'
openlocalkey='O'
gokey='g'
refreshkey='r'
backkey='b'
quitkey='q'
markkey='b'
gomarkkey='M'
delmarkkey='K'
goupkey='u'
homekey='H'
# styles
sty_header1='\033[35;7;1m'
sty_header2='\033[35;4;1m'
sty_header3='\033[35;4m'
sty_quote='\033[2;3m '
sty_linkb='\033[35m'
sty_linkt=' => \033[36;3m '
sty_listb='\033[35;1m •'
sty_listt='\033[0m '
EOF
fi
# shellcheck source=/dev/null
. "$configfile"
mkdir -p "$cachedir"
pagefile="$(mktemp -p "$cachedir" -t curpage.XXXXXX)"
histfile="$(mktemp -p "$cachedir" -t history.XXXXXX)"
linksfile="$(mktemp -p "$cachedir" -t links.XXXXXX)"
tracefile="$(mktemp -p "$cachedir" -t trace.XXXXXX)"
debug "read configs"
# Restore terminal
trap 'rm -f $histfile $linksfile $pagefile $tracefile > /dev/null 2>&1 && tput rmcup && printf "\033[?25h" && stty echo && exit' EXIT INT HUP
stop() {
[ "$trace" ] || return
if [ -z "$stopwatch" ]
then
stopwatch=$(date +%s.%N)
else
dur=$(echo "$(date +%s.%N) - $stopwatch" | bc)
printf "%s took %s seconds\n" "$1" "$dur" >> "$tracefile"
unset stopwatch
fi
}
getprevious() {
sed -i '$d' "$histfile"
prev="$(tail -n 1 "$histfile")"
sed -i '$d' "$histfile"
echo "$prev"
}
# Returns the complete url scheme with gemini defaults
# Parameters: url
parseurl() {
# Credits: https://stackoverflow.com/a/6174447/7618649
debug "parsing: $url oldhost: $oldhost oldpath: $oldpath"
proto="$(echo "$url" | grep :// | sed -e 's,^\(.*://\).*,\1,g')"
if [ "$proto" ]
then
url="$(echo "$url" | sed -e "s@$proto@@g")"
else
if [ "$oldhost" ]
then
case "$url" in
"/"*) url="$oldhost$url" ;;
*) oldpath="/${oldpath#/*}"; url="$oldhost${oldpath%/*}/$url" ;;
esac
fi
fi
debug "url: $url"
proto="$(echo "$proto" | sed -e 's,:\?//,,g')"
user="$(echo "$url" | grep @ | cut -d@ -f1)"
hostport="$(echo "$url" | sed -e "s/$user@//g" | cut -d/ -f1)"
host="$(echo "$hostport" | sed -e 's,:.*,,g')"
port="$(echo "$hostport" | sed -e 's,^.*:,:,g' -e 's,.*:\([0-9]*\).*,\1,g' -e 's,[^0-9],,g')"
path="$(echo "${url#/*}" | sed "s@/\?$hostport@@")"
debug "parsed: proto: ${proto:-gemini} host: $host port: ${port:-1965} path: ${path#/*}"
echo "${proto:-gemini}" "$host" "${port:-1965}" "${path#/*}" "$rest"
}
typesetgmi() {
# some setup first
[ -f "$linksfile" ] && rm "$linksfile"
cols=$(tput cols)
linkcount="0"
# shellcheck disable=SC2154
width=$((cols - (2*margin)))
debug "text width: $width"
stop
while IFS='' read -r line || [ -n "$line" ];
do
# shellcheck disable=SC2059
line="$(printf "$line\n" | tr -d '\r')"
printf "$line\n" | grep -q "^\`\`\`" && pre=$((1 - pre)) && line=""
# add margins and fold
if [ "$pre" = 1 ]
then
printf '%*s%s\n' "$margin" "" "$line"
continue
fi
# shellcheck disable=SC2154
case "$line" in
"### "*) sty="$sty_header3" && line="${line#'### '}" ;;
"## "*) sty="$sty_header2" && line="${line#'## '}" ;;
"# "*) sty="$sty_header1" && line="${line#'# '}" ;;
"> "*) sty="$sty_quote" && line="${line#> }" ;;
"=>"*)
link="$(echo "${line#'=>'}" | tr -s '\t' ' ')"
echo "${link#' '}" >> "$linksfile"
linkcount=$((linkcount+1))
line="$(echo "$link" | cut -d' ' -f2-)"
[ -z "$line" ] && line="$link"
sty="$sty_linkb${linkcount}$sty_linkt"
;;
'* '*) sty="$sty_listb$sty_listt" && line="${line#* }";;
*) sty='' ;;
esac
# shellcheck disable=SC2059
printf -- "$line\n" | fold -w "$width" -s | while IFS='' read -r txt
do
printf "%*s" "$margin" ""
# shellcheck disable=SC2059
printf -- "$sty$txt\n\033[m"
done
done
stop "typeset"
}
# some help:
# \033[;H move to top
# \033[NH move to bottom N lines
# \033[?25l hide cursor
# \033[?25h unhide cursor
# \033[2K erase line
pager() {
clear
# hide cursor
printf '\033[?25l'
# lines columns
lines="$(tput lines)"
head -n "$((lines-1))" "$1"
l="$(wc -l < "$1")"
if [ "$lines" -lt "$l" ]; then pos="$lines"; else pos="$l"; fi
# stop echoing user input and read input unbufered
stty -echo -icanon min 1 time 0
# read inputs
while k="$(dd bs=1 count=1 status=none | od -c -An | tr -d ' ')"
do
case "$k" in
# command sequences
"033")
b="$(dd bs=2 count=1 status=none)"
case "$b" in
# up arrow
'[A')
[ "$pos" -le "$lines" ] && continue
line="$(sed "$((pos-lines))q;d" "$1")"
pos="$((pos-1))"
printf '\033[H\033[L%s\033[%sH\033[2K' "$line" "$lines"
;;
# down arrow
'[B')
[ "$pos" -ge "$l" ] && continue
printf '\033[%sH' "$lines"
sed "${pos}q;d" "$1"
pos="$((pos+1))"
;;
# page up
'[5')
# discard one extra byte
dd bs=1 count=1 status=none > /dev/null
[ "$pos" -le "$lines" ] && continue
scroll="$((pos-lines))"
[ "$scroll" -gt "$lines" ] && scroll="$((lines-1))"
# shellcheck disable=SC2086
for i in $(seq 1 "$scroll")
do
line="$(sed "$((pos-lines))q;d" "$1")"
pos="$((pos-1))"
printf '\033[H\033[L%s\033[%sH\033[2K' "$line" "$lines"
done
;;
# page down
'[6')
# discard one extra byte
dd bs=1 count=1 status=none > /dev/null
[ "$pos" -ge "$l" ] && continue
scroll="$((lines-1))"
end="$((pos+scroll))"
[ "$end" -ge "$l" ] && scroll="$((l-pos))"
# shellcheck disable=SC2086
for i in $(seq 1 "$scroll")
do
printf '\033[%sH' "$lines"
sed "${pos}q;d" "$1"
pos="$((pos+1))"
done
;;
esac ;;
"$quitkey") exit 0 ;;
"$openkey")
printf '\033[%sH\033[?25h\033[2KType url: ' "$lines"
stty echo icanon
read -r url <&1
# add gemini:// to begining if not (for convenience)
case "$url" in
"gemini://"*) ;;
*) url="gemini://$url" ;;
esac
return
;;
"$openlocalkey")
# Open local gmi file
printf '\033[?25h\033[2KType file path: '
stty echo
read -r file <&1
typesetgmi < "$file" > "$pagefile"
pager "$pagefile"
return
;;
"$refreshkey") return ;;
"$gokey")
printf '\033[?25h\033[2KEnter link number: '
stty echo icanon
read -r i <&1
debug "selected $i"
url="$(sed "${i}q;d" "$linksfile" | cut -f1 | cut -d' ' -f1)"
return
;;
"$backkey")
read -r proto host port path <<- EOF
$(getprevious)
EOF
url="$proto://$host:$port/$path"
return
;;
"$homekey") url="$homepage"; return ;;
"$markkey")
printf '\033[?25h\033[KEnter description: (optional)'
stty echo icanon
read -r desc <&1
echo "$url $desc" >> "$bookmarkfile"
return
;;
"$gomarkkey")
clear
cat -n "$bookmarkfile"
printf "\033[?25h\033[KEnter link number: "
stty echo icanon
read -r i <&1
url="$(sed "${i}q;d" "$bookmarkfile" | cut -d' ' -f1)"
return
;;
"$delmarkkey")
grep -iv "^$url " "$bookmarkfile" > "$cachedir/bookmarks"
mv "$cachedir/bookmarks" "$bookmarkfile"
return
;;
"$goupkey")
newpath=$(echo "$url" | rev | cut -d'/' -f2- | rev)
url="${url%/*}/$newpath"
return
;;
esac
done
}
# borrowed from https://gist.github.com/cdown/1163649
urlencode() {
stop
old_lang=$LANG
LANG=C
old_lc_collate=$LC_COLLATE
LC_COLLATE=C
length="${#1}"
i=1
while [ "$i" -le "$length" ]
do
c=$(printf '%s' "$1" | cut -c $i)
case $c in
[a-zA-Z0-9.~_-]) printf '%s' "$c" ;;
*) printf '%%%02X' "'$c" ;;
esac
i=$((i+1))
done
LC_COLLATE=$old_lc_collate
LANG=$old_lang
stop "urlencode"
}
# Fetches the gemini response from server
# Parameters: proto, host, port and path
# Spec draft is here: https://gemini.circumlunar.space/docs/specification.html
fetch() {
if [ ! "$1" = "gemini" ]
then
echo "Only gemini links are supported."
echo "Type a key to continue."
read -r i <&1
read -r proto host port path <<- EOF
$(getprevious)
EOF
url="$proto://$host:$port/$path"
url="${url:-$homepage}"
debug "previous page: $url"
return
fi
debug "requesting $1://$2:$3/$4$5"
# set title
printf '\033]2;%s\007' "astro (c): $2/$4"
echo "$1 $2 $3 $4 $5" >> "$histfile"
certfile=""
if [ -f "$certdir/$2.crt" ] && [ -f "$certdir/$2.key" ]
then
certfile="-cert \"$certdir/$2.crt\" -key \"$certdir/$2.key\""
debug "using client cert for domain: $certfile"
fi
port=$( [ "$3" = "1965" ] || ":$3" )
url="$1://$2$port/$4$5"
[ "$trace" ] && echo "url: $url" >> "$tracefile"
stop
echo "$url" | eval openssl s_client \
-connect "$2:$3" "$certfile" -crlf -quiet \
-ign_eof 2> /dev/null > "$pagefile"
stop "openssl fetch"
stop
printf '\033]2;%s\007' "astro (r): $2/$4"
# First line is status and meta information
read -r status meta < "$pagefile"
status="$(echo "$status" | tr -d '\r\n')"
meta="$(echo "$meta" | tr -d '\r\n')"
sed -i '1d' "$pagefile"
stop "status extract"
debug "response status - meta: $status - $meta"
# Validate
case "$status" in
10)
echo "Input needed: $meta" >&2
echo "Please provide the input:" >&2
read -r input <&1
url="$1://$2:$3/$4?$(urlencode "$input")"
return 0
;;
11)
echo "Sensitive input needed: $meta" >&2
read -r input <&1
url="$1://$2:$3/$4?$(urlencode "$input")"
return 0
;;
30|31)
# Redirect
debug "redirecting to: $meta"
# shellcheck disable=SC2046
read -r proto host port path <<- EOF
$(oldhost="$2" oldpath="$4" url="$meta" parseurl)
EOF
url="$proto://$host:$port/$path"
return 0
;;
40)
echo "Temporary failure" >&2
echo "Type a key to continue. "
read -r i <&1
return 3
;;
41)
return 4
;;
42)
return 5
;;
43)
return 6
;;
44)
return 7
;;
50|51)
echo "Page not found!" >&2
echo "Type a key to return to previous page."
read -r i <&1
read -r proto host port path <<- EOF
$(getprevious)
EOF
url="$proto://$host:$port/$path"
debug "previous page: $url"
return 0
;;
52)
return 10
;;
53)
return 11
;;
59)
echo "Bad request: $meta" >&2
echo "Type a key to continue."
read -r i <&1
return 12
;;
60)
printf "client certificate required, to create a client cert use the following command:\n\n"
printf "\topenssl req -x509 -newkey rsa:4096 -keyout %s/%s.key -out %s/%s.crt -days 36500 -nodes\n\n" "$certdir" "$2" "$certdir" "$2"
printf "press 'return' to reload the page or 'b' to go back to the previous page:\n"
read -r in <&1
if [ "$in" = "b" ]
then
read -r proto host port path <<- EOF
$(getprevious)
EOF
url="$proto://$host:$port/$path"
else
url="$1://$2:$3/$4?$5"
fi
return 0
;;
61)
return 14
;;
62)
return 15
;;
esac
# Success
oldhost="$2"
oldpath="$4"
# Set charset
charset="$(echo "$meta" | grep -i "charset=" | sed -e 's/.*charset=\([^;]\+\).*/\1/Ig')"
case "$charset" in
"iso-8859-1" | "ISO-8859-1") charset="iso8859" ;;
"utf-8" | "UTF-8" | "") charset="utf8" ;;
"us-ascii" | "US-ASCII") charset="ascii" ;;
esac
debug "charset: $charset"
printf '\033]2;%s\007' "astro (t): $2/$4"
case $meta in
"text/gemini"* | "") typesetgmi < "$pagefile" > "$pagefile.gmi"; mv "$pagefile.gmi" "$pagefile" ;;
*) ;;
esac
debug "starting pager"
printf '\033]2;%s\007' "astro (l): $2/$4"
pager "$pagefile"
debug "new url: $url"
}
# files are handled differently
if [ "$file" ] && [ -f "$file" ]
then
typesetgmi < "$file" > "$pagefile"
pager "$pagefile"
fi
# first request
url="${url:-$homepage}"
while :
do
printf '\033]2;%s\007' "astro (w)"
# shellcheck disable=SC2046
fetch $(parseurl)
done