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.
39 # pure bash bible trim_string
41 : "${1#"${1%%[![:space:]]*}"}"
42 : "${_%"${_##*[![:space:]]}"}"
47 [[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
52 [[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
62 printf >&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*"
67 run bollux_config # TODO: figure out better config method
68 run bollux_args "$@" # and argument parsing
71 if [[ ! "${BOLLUX_URL:+x}" ]]; then
72 run prompt GO BOLLUX_URL
75 run blastoff "$BOLLUX_URL"
79 while getopts :hvq OPT; do
85 v) BOLLUX_LOGLEVEL=DEBUG ;;
86 q) BOLLUX_LOGLEVEL=QUIET ;;
87 :) die 1 "Option -$OPTARG requires an argument" ;;
88 *) die 1 "Unknown option: -$OPTARG" ;;
98 : "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/bollux.conf}"
100 if [ -f "$BOLLUX_CONFIG" ]; then
101 # shellcheck disable=1090
104 log debug "Can't load config file '$BOLLUX_CONFIG'."
108 : "${BOLLUX_TIMEOUT:=30}" # connection timeout
109 : "${BOLLUX_MAXREDIR:=5}" # max redirects
110 : "${BOLLUX_PORT:=1965}" # port number
111 : "${BOLLUX_PROTO:=gemini}" # default protocol
112 : "${BOLLUX_URL:=}" # start url
113 : "${BOLLUX_BYEMSG:=See You Space Cowboy...}" # bye message
115 : "${BOLLUX_DATADIR:=${XDG_DATA_DIR:-$HOME/.local/share}/bollux}"
116 : "${BOLLUX_DOWNDIR:=.}" # where to save downloads
117 : "${BOLLUX_LESSKEY:=$BOLLUX_DATADIR/lesskey}" # where to store binds
118 : "${BOLLUX_PAGESRC:=$BOLLUX_DATADIR/pagesrc}" # where to save the source
119 BOLLUX_HISTFILE="$BOLLUX_DATADIR/history" # where to save the history
121 : "${T_MARGIN:=4}" # left and right margin
122 : "${T_WIDTH:=0}" # width of the viewport -- 0 = get term width
123 # colors -- these will be wrapped in \e[ __ m
124 C_RESET='\e[0m' # reset
125 : "${C_SIGIL:=35}" # sigil (=>, #, ##, ###, *, ```)
126 : "${C_LINK_NUMBER:=1}" # link number
127 : "${C_LINK_TITLE:=4}" # link title
128 : "${C_LINK_URL:=36}" # link URL
129 : "${C_HEADER1:=1;4}" # header 1 formatting
130 : "${C_HEADER2:=1}" # header 2 formatting
131 : "${C_HEADER3:=3}" # header 3 formatting
132 : "${C_LIST:=0}" # list formatting
133 : "${C_PRE:=0}" # preformatted text formatting
137 log x "$BOLLUX_BYEMSG"
142 printf '\e]2;%s - bollux\007' "$*"
145 prompt() { # prompt [-u] PROMPT [READ_ARGS...]
146 local read_cmd=(read -e -r)
147 if [[ "$1" == "-u" ]]; then
148 read_cmd+=(-i "$BOLLUX_URL")
153 read_cmd+=(-p "$prompt> ")
154 "${read_cmd[@]}" </dev/tty "$@"
157 blastoff() { # load a url
158 local well_formed=true
160 if [[ "$1" == "-u" ]]; then
166 if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then
167 url="$(run transform_resource "$BOLLUX_URL" "$1")"
169 [[ "$url" != *://* ]] && url="$BOLLUX_PROTO://$url"
173 log d "PROTO='$proto' URL='$url'"
176 if declare -Fp "${proto}_request" >/dev/null; then
177 run "${proto}_request" "$url"
179 log d "No request handler for '$proto'; trying gemini"
180 run gemini_request "$url"
184 if declare -Fp "${proto}_response" >/dev/null; then
185 run "${proto}_response" "$url"
187 log d "No response handler for '$proto'; handling raw response"
193 transform_resource() { # transform_resource BASE_URL REFERENCE_URL
194 local -A R B T # reference, base url, target
195 eval "$(run parse_url B "$1")"
196 eval "$(run parse_url R "$2")"
197 # A non-strict parser may ignore a scheme in the reference
198 # if it is identical to the base URI's scheme.
199 if ! "${STRICT:-true}" && [[ "${R[scheme]}" == "${B[scheme]}" ]]; then
203 # basically pseudo-code from spec ported to bash
204 if isdefined "R[scheme]"; then
205 T[scheme]="${R[scheme]}"
206 isdefined "R[authority]" && T[authority]="${R[authority]}"
208 T[path]="$(run remove_dot_segments "${R[path]}")"
209 isdefined "R[query]" && T[query]="${R[query]}"
211 if isdefined "R[authority]"; then
212 T[authority]="${R[authority]}"
213 isdefined "R[authority]" &&
214 T[path]="$(remove_dot_segments "${R[path]}")"
215 isdefined R[query] && T[query]="${R[query]}"
217 if isempty "R[path]"; then
219 if isdefined R[query]; then
220 T[query]="${R[query]}"
222 T[query]="${B[query]}"
225 if [[ "${R[path]}" == /* ]]; then
226 T[path]="$(remove_dot_segments "${R[path]}")"
228 T[path]="$(merge_paths "B[authority]" "${B[path]}" "${R[path]}")"
229 T[path]="$(remove_dot_segments "${T[path]}")"
231 isdefined R[query] && T[query]="${R[query]}"
233 T[authority]="${B[authority]}"
235 T[scheme]="${B[scheme]}"
237 isdefined R[fragment] && T[fragment]="${R[fragment]}"
238 # cf. 5.3 -- recomposition
240 isdefined "T[scheme]" && r="$r${T[scheme]}:"
241 # remove the port from the authority
242 isdefined "T[authority]" && r="$r//${T[authority]%:*}"
244 isdefined T[query] && r="$r?${T[query]}"
245 isdefined T[fragment] && r="$r#${T[fragment]}"
249 merge_paths() { # 5.2.3
250 # shellcheck disable=2034
251 local B_authority="$1"
254 # if R_path is empty, get rid of // in B_path
255 if [[ -z "$R_path" ]]; then
256 printf '%s\n' "${B_path//\/\//\//}"
260 if isdefined "B_authority" && isempty "B_path"; then
261 printf '/%s\n' "${R_path//\/\//\//}"
263 if [[ "$B_path" == */* ]]; then
264 B_path="${B_path%/*}/"
268 printf '%s/%s\n' "${B_path%/}" "${R_path#/}"
272 remove_dot_segments() { # 5.2.4
275 while [[ "$input" ]]; do
276 if [[ "$input" =~ ^\.\.?/ ]]; then
277 input="${input#${BASH_REMATCH[0]}}"
278 elif [[ "$input" =~ ^/\.(/|$) ]]; then
279 input="/${input#${BASH_REMATCH[0]}}"
280 elif [[ "$input" =~ ^/\.\.(/|$) ]]; then
281 input="/${input#${BASH_REMATCH[0]}}"
282 [[ "$output" =~ /?[^/]+$ ]]
283 output="${output%${BASH_REMATCH[0]}}"
284 elif [[ "$input" == . || "$input" == .. ]]; then
287 [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || log debug NOMATCH
288 output="$output${BASH_REMATCH[1]}"
289 input="${BASH_REMATCH[2]}"
292 printf '%s\n' "${output//\/\//\//}"
295 parse_url() { # eval "$(split_url NAME STRING)" => NAME[...]
298 # shopt -u extglob # TODO port re ^ to extglob syntax
299 local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
300 [[ $string =~ $re ]] || return $?
303 local scheme="${BASH_REMATCH[2]}"
304 local authority="${BASH_REMATCH[4]}"
305 local path="${BASH_REMATCH[5]}"
306 local query="${BASH_REMATCH[7]}"
307 local fragment="${BASH_REMATCH[9]}"
309 for c in scheme authority query fragment; do
311 run printf '%s[%s]=%q\n' "$name" "$c" "${!c}"
313 # unclear if the path is always set even if empty but it looks that way
314 run printf '%s[path]=%q\n' "$name" "$path"
317 # is a NAME defined ('set' in bash)?
318 isdefined() { [[ "${!1+x}" ]]; } # isdefined NAME
319 # is a NAME defined AND empty?
320 isempty() { [[ ! "${!1-x}" ]]; } # isempty NAME
321 # split a string -- see pure bash bible
322 split() { # split STRING DELIMITER
324 IFS=$'\n' read -d "" -ra arr <<<"${1//$2/$'\n'}"
325 printf '%s\n' "${arr[@]}"
329 # https://gemini.circumlunar.space/docs/spec-spec.txt
331 local url port server
336 server="${server%%/*}"
338 ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port")
339 # disable old TLS/SSL versions (thanks makeworld!)
340 ssl_cmd+=(-no_ssl3 -no_tls1 -no_tls1_1)
342 # always try to connect with TLS v1.3 first
343 run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null
351 # we need a loop here so it waits for the first line
352 while read -t "$BOLLUX_TIMEOUT" -r code meta ||
353 { (($? > 128)) && die 99 "Timeout."; }; do
357 log d "[$code] $meta"
363 run blastoff "?$REPLY"
367 # read ahead to find a title
370 pretitle="$pretitle$REPLY"$'\n'
371 if [[ "$REPLY" =~ ^#[[:space:]]*(.*) ]]; then
372 title="${BASH_REMATCH[1]}"
376 run history_append "$url" "${title:-}"
377 # read the body out and pipe it to display
379 printf '%s' "$pretitle"
381 } | run display "$meta" "${title:-}"
385 if ((REDIRECTS > BOLLUX_MAXREDIR)); then
386 die $((100 + code)) "Too many redirects!"
388 run blastoff "$meta" # TODO: confirm redirect
390 4*) # temporary error
392 die "$((100 + code))" "Temporary error [$code]: $meta"
394 5*) # permanent error
396 die "$((100 + code))" "Permanent error [$code]: $meta"
398 6*) # certificate error
400 log d "Not implemented: Client certificates"
401 # TODO: recheck the speck
402 die "$((100 + code))" "[$code] $meta"
405 [[ -z "${code-}" ]] && die 100 "Empty response code."
406 die "$((100 + code))" "Unknown response code: $code."
412 # https://tools.ietf.org/html/rfc1436 protocol
413 # https://tools.ietf.org/html/rfc4266 url
415 local url server port type path
420 [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]]
421 server="${BASH_REMATCH[1]}"
422 port="${BASH_REMATCH[3]:-70}"
423 type="${BASH_REMATCH[6]:-1}"
424 path="${BASH_REMATCH[7]}"
426 log d "URL='$url' SERVER='$server' TYPE='$type' PATH='$path'"
428 exec 9<>"/dev/tcp/$server/$port"
429 printf '%s\r\n' "$path" >&9
434 local url pre type cur_server
438 [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]]
439 cur_server="${BASH_REMATCH[1]}"
440 type="${BASH_REMATCH[6]:-1}"
442 run history_append "$url" "" # TODO: get the title ??
448 run display text/plain
451 run gopher_convert | run display text/gemini
454 die 203 "GOPHER: failed"
457 if [[ "$url" =~ $'\t' ]]; then
458 run gopher_convert | run display text/gemini
461 run blastoff "$url $REPLY"
471 while IFS= read -r; do
472 printf '%s\n' "$REPLY"
477 local type label path server port regex
478 # cf. https://github.com/jamestomasino/dotfiles-minimal/blob/master/bin/gophermap2gemini.awk
479 while IFS= read -r; do
480 printf -v regex '(.)([^\t]*)(\t([^\t]*)\t([^\t]*)\t([^\t]*))?'
481 if [[ "$REPLY" =~ $regex ]]; then
482 type="${BASH_REMATCH[1]}"
483 label="${BASH_REMATCH[2]}"
484 path="${BASH_REMATCH[4]:-/}"
485 server="${BASH_REMATCH[5]:-$cur_server}"
486 port="${BASH_REMATCH[6]}"
488 log e "CAN'T PARSE LINE"
489 printf '%s\n' "$REPLY"
499 '#'* | '*'[[:space:]]*)
512 printf '%s\n' "$label"
519 printf '=> %s %s\n' "${path:4}" "$label"
526 printf '=> telnet://%s:%s/%s%s %s\n' \
527 "$server" "$port" "$type" "$path" "$label"
534 printf '=> gopher://%s:%s/%s%s %s\n' \
535 "$server" "$port" "$type" "$path" "$label"
542 # close the connection
547 display() { # display METADATA [TITLE]
552 IFS=';' read -ra hdr <<<"$1"
553 # title is optional but nice looking
559 mime="$(trim "${hdr[0],,}")"
560 for ((i = 1; i <= "${#hdr[@]}"; i++)); do
563 *charset=*) charset="${h#*=}" ;;
567 [[ -z "$mime" ]] && mime="text/gemini"
568 [[ -z "$charset" ]] && charset="utf-8"
570 log debug "mime='$mime'; charset='$charset'"
574 set_title "$BOLLUX_URL"
576 mklesskey "$BOLLUX_LESSKEY" && less_cmd+=(-k "$BOLLUX_LESSKEY")
578 -Pm"$title${title:+ - }bollux$"
579 -PM'o\:open, g\:goto, [\:back, ]\:forward, r\:refresh$'
584 local submime="${mime#*/}"
585 if declare -Fp "typeset_$submime" >/dev/null; then
586 typeset="typeset_$submime"
592 run iconv -f "${charset^^}" -t "UTF-8" |
593 run tee "$BOLLUX_PAGESRC" |
595 run "${less_cmd[@]}" && bollux_quit
596 } || run handle_keypress "$?"
598 *) run download "$BOLLUX_URL" ;;
603 lesskey -o "$1" - <<-END
605 o quit 0 # 48 open a link
606 g quit 1 # 49 goto a url
608 ] quit 3 # 51 forward
609 r quit 4 # 52 re-request / download
610 G quit 5 # 53 goto a url (pre-filled)
612 \\40 forw-screen-force
618 while IFS= read -r; do
619 printf '%s\n' "${REPLY//$'\r'?($'\n')/}"
626 local ln=0 # link number
628 if ((T_WIDTH == 0)); then
629 shopt -s checkwinsize
633 ) # XXX this doesn't work!?
634 log d "LINES=$LINES; COLUMNS=$COLUMNS"
637 WIDTH=$((T_WIDTH - T_MARGIN))
638 ((WIDTH < 0)) && WIDTH=80 # default if dumb
639 S_MARGIN=$((T_MARGIN - 1)) # spacing
641 log d "T_WIDTH=$T_WIDTH"
644 while IFS= read -r; do
656 gemini_link "$REPLY" $pre "$ln"
658 '#'*) gemini_header "$REPLY" $pre ;;
660 gemini_list "$REPLY" $pre
662 *) gemini_text "$REPLY" $pre ;;
668 local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)"
669 local s t a l # sigil, text, annotation(url), line
670 if ! ${2-false} && [[ "$1" =~ $re ]]; then
671 s="${BASH_REMATCH[1]}"
672 a="${BASH_REMATCH[2]}"
673 t="${BASH_REMATCH[3]}"
674 if [[ -z "$t" ]]; then
679 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
680 printf -v l "\e[${C_LINK_NUMBER}m[%d]${C_RESET} \
681 \e[${C_LINK_TITLE}m%s${C_RESET} \
682 \e[${C_LINK_URL}m%s${C_RESET}\n" \
684 fold_line "$WIDTH" "$l"
691 local re="^(#+)[[:blank:]]*(.*)"
692 local s t a l # sigil, text, annotation(lvl), line
693 if ! ${2-false} && [[ "$1" =~ $re ]]; then
694 s="${BASH_REMATCH[1]}"
695 a="${#BASH_REMATCH[1]}"
696 t="${BASH_REMATCH[2]}"
698 hdrfmt="$(eval echo "\$C_HEADER$a")"
700 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
701 printf -v l "\e[${hdrfmt}m%s${C_RESET}\n" "$t"
702 fold_line "$WIDTH" "$l"
709 local re="^(\*)[[:blank:]]*(.*)"
710 local s t a l # sigil, text, annotation(n/a), line
711 if ! ${2-false} && [[ "$1" =~ $re ]]; then
712 s="${BASH_REMATCH[1]}"
713 t="${BASH_REMATCH[2]}"
715 printf "\e[${C_SIGIL}m%${S_MARGIN}s " "$s"
716 printf -v l "\e[${C_LIST}m%s${C_RESET}\n" "$t"
717 fold_line "$WIDTH" "$l"
724 if ! ${2-false}; then
725 printf "%${S_MARGIN}s " ' '
726 fold_line "$WIDTH" "$1"
733 printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```'
734 printf "\e[${C_PRE}m%s${C_RESET}\n" "$1"
737 fold_line() { # fold_line WIDTH TEXT
739 local margin="${2%%[![:space:]]*}"
740 if [[ "$margin" ]]; then
746 # shellcheck disable=2086
747 set -- $2 # TODO: is this the best way?
750 plain="${word//$'\x1b'\[*([0-9;])m/}"
751 wl=$((${#plain} + 1))
752 if (((ll + wl) >= width)); then
753 printf "\n%${margin}s" ' '
765 48) # o - open a link -- show a menu of links on the page
766 run select_url "$BOLLUX_PAGESRC"
768 49) # g - goto a url -- input a new url
770 run blastoff -u "$REPLY"
772 50) # [ - back in the history
773 run history_back || {
775 run blastoff "$BOLLUX_URL"
778 51) # ] - forward in the history
779 run history_forward || {
781 run blastoff "$BOLLUX_URL"
784 52) # r - re-request the current resource
785 run blastoff "$BOLLUX_URL"
787 53) # G - goto a url (pre-filled with current)
789 run blastoff -u "$REPLY"
791 *) # 54-57 -- still available for binding
792 die "$?" "less(1) error"
798 run mapfile -t < <(extract_links <"$1")
800 select u in "${MAPFILE[@]}"; do
803 [^0-9]*) run blastoff -u "$REPLY" && break ;;
805 run blastoff "${u%%[[:space:]]*}" && break
812 if [[ "$REPLY" =~ ^=\>[[:space:]]*([^[:space:]]+)([[:space:]]+(.*))?$ ]]; then
813 url="${BASH_REMATCH[1]}"
814 alt="${BASH_REMATCH[3]}"
816 if [[ "$alt" ]]; then
817 printf '%s \e[34m(%s)\e[0m\n' "$url" "$alt"
827 log x "Downloading: '$BOLLUX_URL' => '$tn'..."
828 dd status=progress >"$tn"
829 fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}"
830 if [[ -f "$fn" ]]; then
832 elif mv "$tn" "$fn"; then
835 log error "Error saving '$fn': downloaded to '$tn'."
841 trap bollux_cleanup INT QUIT EXIT
846 declare -a HISTORY # history is kept in an array
847 HN=0 # position of history in the array
848 run mkdir -p "${BOLLUX_HISTFILE%/*}"
857 history_append() { # history_append url TITLE
859 # date/time, url, title (best guess)
860 run printf '%(%FT%T)T\t%s\t%s\n' -1 "$1" "$2" >>"$BOLLUX_HISTFILE"
861 HISTORY[$HN]="$BOLLUX_URL"
870 log e "Beginning of history."
873 run blastoff "${HISTORY[$HN]}"
878 if ((HN >= ${#HISTORY[@]})); then
880 log e "End of history."
883 run blastoff "${HISTORY[$HN]}"
886 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then