2 # bollux: a bash gemini client
3 # Author: Case Duckworth
13 $PRGN (v. $VRSN): a bash gemini client
18 -h show this help and exit
19 -q be quiet: log no messages
20 -v verbose: log more messages
22 URL the URL to start in
23 If not provided, the user will be prompted.
27 run() { # run COMMAND...
28 trap bollux_quit SIGINT
33 die() { # die EXIT_CODE MESSAGE
40 # builtin replacement for `sleep`
41 # https://github.com/dylanaraps/pure-bash-bible#use-read-as-an-alternative-to-the-sleep-command
42 sleep() { # sleep SECONDS
43 read -rt "$1" <> <(:) || :
46 # https://github.com/dylanaraps/pure-bash-bible/
47 trim_string() { # trim_string STRING
48 : "${1#"${1%%[![:space:]]*}"}"
49 : "${_%"${_##*[![:space:]]}"}"
53 log() { # log LEVEL MESSAGE
54 [[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
59 [[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
69 printf >&2 '\e[%sm%s:%s:\e[0m\t%s\n' "$fmt" "$PRGN" "${FUNCNAME[1]}" "$*"
74 run bollux_config # TODO: figure out better config method
75 run bollux_args "$@" # and argument parsing
78 if [[ ! "${BOLLUX_URL:+x}" ]]; then
79 run prompt GO BOLLUX_URL
82 log d "BOLLUX_URL='$BOLLUX_URL'"
84 run blastoff -u "$BOLLUX_URL"
87 # process command-line arguments
89 while getopts :hvq OPT; do
95 v) BOLLUX_LOGLEVEL=DEBUG ;;
96 q) BOLLUX_LOGLEVEL=QUIET ;;
97 :) die 1 "Option -$OPTARG requires an argument" ;;
98 *) die 1 "Unknown option: -$OPTARG" ;;
101 shift $((OPTIND - 1))
107 # process config file and set variables
109 : "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/bollux.conf}"
111 if [ -f "$BOLLUX_CONFIG" ]; then
112 # shellcheck disable=1090
115 log debug "Can't load config file '$BOLLUX_CONFIG'."
119 : "${BOLLUX_TIMEOUT:=30}" # connection timeout
120 : "${BOLLUX_MAXREDIR:=5}" # max redirects
121 : "${BOLLUX_PORT:=1965}" # port number
122 : "${BOLLUX_PROTO:=gemini}" # default protocol
123 : "${BOLLUX_URL:=}" # start url
124 : "${BOLLUX_BYEMSG:=See You Space Cowboy ...}" # bye message
126 : "${BOLLUX_DATADIR:=${XDG_DATA_DIR:-$HOME/.local/share}/bollux}"
127 : "${BOLLUX_DOWNDIR:=.}" # where to save downloads
128 : "${BOLLUX_LESSKEY:=$BOLLUX_DATADIR/lesskey}" # where to store binds
129 : "${BOLLUX_PAGESRC:=$BOLLUX_DATADIR/pagesrc}" # where to save source
130 BOLLUX_HISTFILE="$BOLLUX_DATADIR/history" # where to save history
132 : "${T_MARGIN:=4}" # left and right margin
133 : "${T_WIDTH:=0}" # width of the viewport -- 0 = get term width
134 # colors -- these will be wrapped in \e[ __ m
135 C_RESET='\e[0m' # reset
136 : "${C_SIGIL:=35}" # sigil (=>, #, ##, ###, *, ```)
137 : "${C_LINK_NUMBER:=1}" # link number
138 : "${C_LINK_TITLE:=4}" # link title
139 : "${C_LINK_URL:=36}" # link URL
140 : "${C_HEADER1:=1;4}" # header 1 formatting
141 : "${C_HEADER2:=1}" # header 2 formatting
142 : "${C_HEADER3:=3}" # header 3 formatting
143 : "${C_LIST:=0}" # list formatting
144 : "${C_QUOTE:=3}" # quote formatting
145 : "${C_PRE:=0}" # preformatted text formatting
152 printf '\e[1m%s\e[0m:\t\e[3m%s\e[0m\n' "$PRGN" "$BOLLUX_BYEMSG"
156 trap bollux_quit SIGINT
158 # set the terminal title
159 set_title() { # set_title STRING
160 printf '\e]2;%s\007' "$*"
164 prompt() { # prompt [-u] PROMPT [READ_ARGS...]
165 local read_cmd=(read -e -r)
166 if [[ "$1" == "-u" ]]; then
167 read_cmd+=(-i "$BOLLUX_URL")
172 read_cmd+=(-p "$prompt> ")
173 "${read_cmd[@]}" </dev/tty "$@"
177 blastoff() { # blastoff [-u] URL
180 if [[ "$1" == "-u" ]]; then
181 u="$(run uwellform "$2")"
187 run utransform url "$BOLLUX_URL" "$u"
188 if ! ucdef url[1]; then
189 run ucset url[1] "$BOLLUX_PROTO"
193 if declare -Fp "${url[1]}_request" >/dev/null 2>&1; then
194 run "${url[1]}_request" "$url"
196 die 99 "No request handler for '${url[1]}'"
198 } | run normalize | {
199 if declare -Fp "${url[1]}_response" >/dev/null 2>&1; then
200 run "${url[1]}_response" "$url"
203 "No response handler for '${url[1]}';" \
211 ## https://tools.ietf.org/html/rfc3986
215 if [[ "$u" != *://* ]]; then
216 u="$BOLLUX_PROTO://$u"
219 u="$(trim_string "$u")"
224 usplit() { # usplit NAME:ARRAY URL:STRING
225 local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
226 [[ $2 =~ $re ]] || return $?
228 # shellcheck disable=2034
229 local scheme="${BASH_REMATCH[2]}" \
230 authority="${BASH_REMATCH[4]}" \
231 path="${BASH_REMATCH[5]}" \
232 query="${BASH_REMATCH[7]}" \
233 fragment="${BASH_REMATCH[9]}"
235 # 0=url 1=scheme 2=authority 3=path 4=query 5=fragment
237 for c in scheme authority path query fragment; do
238 if [[ "${!c}" || "$c" == path ]]; then
239 printf -v "$1[$i]" '%s' "${!c}"
241 # shellcheck disable=2059
242 printf -v "$1[$i]" "$UC_BLANK"
246 # shellcheck disable=2059
247 printf -v "$1[0]" "$(ujoin "$1")" # inefficient I'm sure
250 ujoin() { # ujoin NAME:ARRAY
254 printf -v U[0] "%s:" "${U[1]}"
258 printf -v U[0] "${U[0]}//%s" "${U[2]}"
261 printf -v U[0] "${U[0]}%s" "${U[3]}"
264 printf -v U[0] "${U[0]}?%s" "${U[4]}"
268 printf -v U[0] "${U[0]}#%s" "${U[5]}"
274 ucdef() { [[ "${!1}" != "$UC_BLANK" ]]; } # ucdef NAME
275 ucblank() { [[ -z "${!1}" ]]; } # ucblank NAME
276 ucset() { # ucset NAME VALUE
278 run ujoin "${1/\[*\]/}"
281 utransform() { # utransform TARGET:ARRAY BASE:STRING REFERENCE:STRING
282 local -a B R # base, reference
283 local -n T="$1" # target
288 for ((i = 1; i <= 5; i++)); do
292 # 0=url 1=scheme 2=authority 3=path 4=query 5=fragment
299 T[3]="$(pundot "${R[3]}")"
308 T[3]="$(pundot "${R[3]}")"
314 if ucblank R[3]; then
322 if [[ "${R[3]}" == /* ]]; then
323 T[3]="$(pundot "${R[3]}")"
326 T[3]="$(pundot "${T[3]}")"
343 pundot() { # pundot PATH:STRING
346 while [[ "$input" ]]; do
347 if [[ "$input" =~ ^\.\.?/ ]]; then
348 input="${input#${BASH_REMATCH[0]}}"
349 elif [[ "$input" =~ ^/\.(/|$) ]]; then
350 input="/${input#${BASH_REMATCH[0]}}"
351 elif [[ "$input" =~ ^/\.\.(/|$) ]]; then
352 input="/${input#${BASH_REMATCH[0]}}"
353 [[ "$output" =~ /?[^/]+$ ]]
354 output="${output%${BASH_REMATCH[0]}}"
355 elif [[ "$input" == . || "$input" == .. ]]; then
358 [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || return 1
359 output="$output${BASH_REMATCH[1]}"
360 input="${BASH_REMATCH[2]}"
363 printf '%s\n' "${output//\/\//\//}"
370 if ucblank r[3]; then
371 printf '%s\n' "${b[3]//\/\//\//}"
375 if ucdef b[2] && ucblank b[3]; then
376 printf '/%s\n' "${r[3]//\/\//\//}"
379 if [[ "${b[3]}" == */* ]]; then
382 printf '%s/%s\n' "${bp%/}" "${r[3]#/}"
386 # https://github.com/dylanaraps/pure-bash-bible/
387 uencode() { # uencode URL:STRING
389 for ((i = 0; i < ${#1}; i++)); do
396 printf '%%%02X' "'$_"
403 # https://github.com/dylanaraps/pure-bash-bible/
404 udecode() { # udecode URL:STRING
406 printf '%b\n' "${_//%/\\x}"
410 # https://gemini.circumlunar.space/docs/specification.html
411 gemini_request() { # gemini_request URL
415 # get rid of userinfo
416 ucset url[2] "${url[2]#*@}"
419 if [[ "${url[2]}" == *:* ]]; then
421 ucset url[2] "${url[2]%:*}"
423 port=1965 # TODO variablize
428 -crlf -quiet -connect "${url[2]}:$port"
429 -servername "${url[2]}" # SNI
430 -no_ssl3 -no_tls1 -no_tls1_1 # disable old TLS/SSL versions
433 run "${ssl_cmd[@]}" <<<"$url"
436 gemini_response() { # gemini_response URL
441 # we need a loop here so it waits for the first line
442 while read -t "$BOLLUX_TIMEOUT" -r code meta ||
443 { (($? > 128)) && die 99 "Timeout."; }; do
447 log d "[$code] $meta"
454 10) run prompt "$meta" ;;
455 11) run prompt "$meta" -s ;; # password input
457 run blastoff "?$(uencode "$REPLY")"
462 # read ahead to find a title
465 pretitle="$pretitle$REPLY"$'\n'
466 if [[ "$REPLY" =~ ^#[[:space:]]*(.*) ]]; then
467 title="${BASH_REMATCH[1]}"
471 run history_append "$url" "${title:-}"
472 # read the body out and pipe it to display
474 printf '%s' "$pretitle"
476 } | run display "$meta" "${title:-}"
480 if ((REDIRECTS > BOLLUX_MAXREDIR)); then
481 die $((100 + code)) "Too many redirects!"
484 run blastoff "$meta" # TODO: confirm redirect
486 4*) # temporary error
488 die "$((100 + code))" "Temporary error [$code]: $meta"
490 5*) # permanent error
492 die "$((100 + code))" "Permanent error [$code]: $meta"
494 6*) # certificate error
496 log d "Not implemented: Client certificates"
497 # TODO: recheck the speck
498 die "$((100 + code))" "[$code] $meta"
501 [[ -z "${code-}" ]] && die 100 "Empty response code."
502 die "$((100 + code))" "Unknown response code: $code."
508 # https://tools.ietf.org/html/rfc1436 protocol
509 # https://tools.ietf.org/html/rfc4266 url
510 gopher_request() { # gopher_request URL
511 local url server port type path
516 [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]]
517 server="${BASH_REMATCH[1]}"
518 port="${BASH_REMATCH[3]:-70}"
519 type="${BASH_REMATCH[6]:-1}"
520 path="${BASH_REMATCH[7]}"
522 log d "URL='$url' SERVER='$server' TYPE='$type' PATH='$path'"
524 exec 9<>"/dev/tcp/$server/$port"
525 printf '%s\r\n' "$path" >&9
529 gopher_response() { # gopher_response URL
530 local url pre type cur_server
534 [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]]
535 cur_server="${BASH_REMATCH[1]}"
536 type="${BASH_REMATCH[6]:-1}"
538 run history_append "$url" "" # gopher doesn't really have titles, huh
544 run display text/plain
547 run gopher_convert | run display text/gemini
550 die 203 "GOPHER: failed"
553 if [[ "$url" =~ $'\t' ]]; then
554 run gopher_convert | run display text/gemini
557 run blastoff "$url $REPLY"
566 # 'cat' but in pure bash
568 while IFS= read -r; do
569 printf '%s\n' "$REPLY"
573 # convert gophermap to text/gemini (probably naive)
575 local type label path server port regex
576 # cf. https://github.com/jamestomasino/dotfiles-minimal/blob/master/bin/gophermap2gemini.awk
577 while IFS= read -r; do
578 printf -v regex '(.)([^\t]*)(\t([^\t]*)\t([^\t]*)\t([^\t]*))?'
579 if [[ "$REPLY" =~ $regex ]]; then
580 type="${BASH_REMATCH[1]}"
581 label="${BASH_REMATCH[2]}"
582 path="${BASH_REMATCH[4]:-/}"
583 server="${BASH_REMATCH[5]:-$cur_server}"
584 port="${BASH_REMATCH[6]}"
586 log e "CAN'T PARSE LINE"
587 printf '%s\n' "$REPLY"
597 '#'* | '*'[[:space:]]*)
610 printf '%s\n' "$label"
617 printf '=> %s %s\n' "${path:4}" "$label"
624 printf '=> telnet://%s:%s/%s%s %s\n' \
625 "$server" "$port" "$type" "$path" "$label"
632 printf '=> gopher://%s:%s/%s%s %s\n' \
633 "$server" "$port" "$type" "$path" "$label"
640 # close the connection
645 # display the fetched content
646 display() { # display METADATA [TITLE]
651 IFS=';' read -ra hdr <<<"$1"
652 # title is optional but nice looking
658 mime="$(trim_string "${hdr[0],,}")"
659 for ((i = 1; i <= "${#hdr[@]}"; i++)); do
662 *charset=*) charset="${h#*=}" ;;
666 [[ -z "$mime" ]] && mime="text/gemini"
667 [[ -z "$charset" ]] && charset="utf-8"
669 log debug "mime='$mime'; charset='$charset'"
673 set_title "$title${title:+ - }bollux"
674 # render ANSI color escapes and don't wrap pre-formatted blocks
676 mklesskey "$BOLLUX_LESSKEY" && less_cmd+=(-k "$BOLLUX_LESSKEY")
677 local helpline="o:open, g/G:goto, [:back, ]:forward, r:refresh"
680 -Pm"$(less_prompt_escape "$BOLLUX_URL") - bollux$"
682 -P="$(less_prompt_escape "$helpline")$"
683 # start with statusline
685 # float content to the top
690 local submime="${mime#*/}"
691 if declare -Fp "typeset_$submime" &>/dev/null; then
692 typeset="typeset_$submime"
698 run iconv -f "${charset^^}" -t "UTF-8" |
699 run tee "$BOLLUX_PAGESRC" |
700 run "$typeset" | #cat
701 run "${less_cmd[@]}" && bollux_quit
702 } || run handle_keypress "$?"
704 *) run download "$BOLLUX_URL" ;;
708 # escape strings for the less prompt
709 less_prompt_escape() { # less_prompt_escape STRING
711 for ((i = 0; i < ${#1}; i++)); do
714 [\?:\.%\\]) printf '\%s' "$_" ;;
715 *) printf '%s' "$_" ;;
721 # generate a lesskey(1) file for custom keybinds
722 mklesskey() { # mklesskey FILENAME
723 lesskey -o "$1" - <<-END
725 o quit 0 # 48 open a link
726 g quit 1 # 49 goto a url
728 ] quit 3 # 51 forward
729 r quit 4 # 52 re-request / download
730 G quit 5 # 53 goto a url (pre-filled)
732 \\40 forw-screen-force
735 ? status # 'status' will show a little help thing.
743 while IFS= read -r; do
744 # normalize line endings
745 printf '%s\n' "${REPLY//$'\r'?($'\n')/}"
750 # typeset a text/gemini document
753 local ln=0 # link number
755 if ((T_WIDTH == 0)); then
756 shopt -s checkwinsize
760 ) # dumb formatting brought to you by shfmt
761 log d "LINES=$LINES; COLUMNS=$COLUMNS"
764 WIDTH=$((T_WIDTH - T_MARGIN))
765 ((WIDTH < 0)) && WIDTH=80 # default if dumb
766 S_MARGIN=$((T_MARGIN - 1)) # spacing
768 log d "T_WIDTH=$T_WIDTH"
771 while IFS= read -r; do
783 gemini_link "$REPLY" $pre "$ln"
785 '#'*) gemini_header "$REPLY" $pre ;;
787 gemini_list "$REPLY" $pre
790 gemini_quote "$REPLY" $pre
792 *) gemini_text "$REPLY" $pre ;;
798 local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)"
799 local s t a # sigil, text, annotation(url)
801 if ! ${2-false} && [[ "$1" =~ $re ]]; then
802 s="${BASH_REMATCH[1]}"
803 a="${BASH_REMATCH[2]}"
804 t="${BASH_REMATCH[3]}"
805 if [[ -z "$t" ]]; then
810 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
811 printf "\e[${C_LINK_NUMBER}m[%d]${C_RESET} " "$ln"
812 fold_line -n -B "\e[${C_LINK_TITLE}m" -A "${C_RESET}" \
813 -l "$((${#ln} + 3))" -m "${T_MARGIN}" \
814 "$WIDTH" "$(trim_string "$t")"
815 fold_line -B " \e[${C_LINK_URL}m" \
817 -l "$((${#ln} + 3 + ${#t}))" \
818 -m "$((T_MARGIN + ${#ln} + 2))" \
826 local re="^(#+)[[:blank:]]*(.*)"
827 local s t a # sigil, text, annotation(lvl)
828 if ! ${2-false} && [[ "$1" =~ $re ]]; then
829 s="${BASH_REMATCH[1]}"
830 a="${#BASH_REMATCH[1]}"
831 t="${BASH_REMATCH[2]}"
833 hdrfmt="$(eval echo "\$C_HEADER$a")"
835 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
836 fold_line -B "\e[${hdrfmt}m" -A "${C_RESET}" -m "${T_MARGIN}" \
844 local re="^(\*)[[:blank:]]*(.*)"
845 local s t # sigil, text
846 if ! ${2-false} && [[ "$1" =~ $re ]]; then
847 s="${BASH_REMATCH[1]}"
848 t="${BASH_REMATCH[2]}"
850 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
851 fold_line -B "\e[${C_LIST}m" -A "${C_RESET}" -m "$T_MARGIN" \
859 local re="^(>)[[:blank:]]*(.*)"
860 local s t # sigil, text
861 if ! ${2-false} && [[ "$1" =~ $re ]]; then
862 s="${BASH_REMATCH[1]}"
863 t="${BASH_REMATCH[2]}"
865 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
866 fold_line -B "\e[${C_QUOTE}m" -A "${C_RESET}" -m "$T_MARGIN" \
874 if ! ${2-false}; then
875 printf "%${S_MARGIN}s " ' '
876 fold_line -m "$T_MARGIN" \
884 printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```'
885 printf "\e[${C_PRE}m%s${C_RESET}\n" "$1"
888 # wrap lines on words to WIDTH
889 fold_line() { # fold_line [OPTIONS...] WIDTH TEXT
890 # see getopts, below, for options
892 local -i margin_all=0 margin_first=0 width ll=0 wl=0 wn=0
893 local before="" after=""
895 while getopts nm:f:l:B:A: OPT; do
897 n) # -n = no trailing newline
900 m) # -m MARGIN = margin for all lines
903 f) # -f MARGIN = margin for first line
904 margin_first="$OPTARG"
906 l) # -l LENGTH = length of line before starting fold
909 B) # -B BEFORE = text to insert before each line
912 A) # -A AFTER = text to insert after each line
918 shift "$((OPTIND - 1))"
921 #shellcheck disable=2086
925 if ((margin_first > 0 && ll == 0)); then
926 printf "%${margin_first}s" " "
928 if [[ -n "$before" ]]; then
929 printf '%b' "$before"
934 plain="${word//$'\x1b'\[*([0-9;])m/}"
936 wl=$((${#plain} + 1))
937 if (((ll + wl) >= width)); then
938 printf "${after:-}\n%${margin_all}s${before:-}" ' '
944 ((wn != $#)) && printf ' '
946 [[ -n "$after" ]] && printf '%b' "$after"
947 $newline && printf '\n'
950 # use the exit code from less (see mklesskey) to do things
951 handle_keypress() { # handle_keypress CODE
953 48) # o - open a link -- show a menu of links on the page
954 run select_url "$BOLLUX_PAGESRC"
956 49) # g - goto a url -- input a new url
958 run blastoff -u "$REPLY"
960 50) # [ - back in the history
961 run history_back || {
963 run blastoff "$BOLLUX_URL"
966 51) # ] - forward in the history
967 run history_forward || {
969 run blastoff "$BOLLUX_URL"
972 52) # r - re-request the current resource
973 run blastoff "$BOLLUX_URL"
975 53) # G - goto a url (pre-filled with current)
977 run blastoff -u "$REPLY"
979 *) # 54-57 -- still available for binding
980 die "$?" "less(1) error"
985 # select a URL from a text/gemini file
986 select_url() { # select_url FILE
987 run mapfile -t < <(extract_links <"$1")
988 if ((${#MAPFILE[@]} == 0)); then
989 log e "No links on this page!"
991 run blastoff "$BOLLUX_URL"
994 select u in "${MAPFILE[@]}"; do
997 [^0-9]*) run blastoff -u "$REPLY" && break ;;
999 run blastoff "${u%%[[:space:]]*}" && break
1003 # extract the links from a text/gemini file
1006 local re="^=>[[:space:]]*([^[:space:]]+)([[:space:]]+(.*))?$"
1009 if [[ $REPLY =~ $re ]]; then
1010 url="${BASH_REMATCH[1]}"
1011 alt="${BASH_REMATCH[3]}"
1013 if [[ "$alt" ]]; then
1014 printf '%s \e[34m(%s)\e[0m\n' "$url" "$alt"
1016 printf '%s\n' "$url"
1022 # download $BOLLUX_URL
1025 log x "Downloading: '$BOLLUX_URL' => '$tn'..."
1026 dd status=progress >"$tn"
1027 fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}"
1028 if [[ -f "$fn" ]]; then
1029 log x "Saved '$tn'."
1030 elif mv "$tn" "$fn"; then
1031 log x "Saved '$fn'."
1033 log error "Error saving '$fn': downloaded to '$tn'."
1040 trap bollux_cleanup INT QUIT EXIT
1045 declare -a HISTORY # history is kept in an array
1046 HN=0 # position of history in the array
1047 run mkdir -p "${BOLLUX_HISTFILE%/*}"
1052 # Stubbed in case of need in future
1056 # append a URL to history
1057 history_append() { # history_append URL TITLE
1059 # date/time, url, title (best guess)
1060 run printf '%(%FT%T)T\t%s\t%s\n' -1 "$1" "$2" >>"$BOLLUX_HISTFILE"
1061 HISTORY[$HN]="$BOLLUX_URL"
1065 # move back in history (session)
1071 log e "Beginning of history."
1074 run blastoff "${HISTORY[$HN]}"
1077 # move forward in history (session)
1080 if ((HN >= ${#HISTORY[@]})); then
1082 log e "End of history."
1085 run blastoff "${HISTORY[$HN]}"
1088 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then