2 # bollux: a bash gemini client
3 # Author: Case Duckworth
16 $PRGN (v. $VRSN): a bash gemini client
21 -h show this help and exit
22 -q be quiet: log no messages
23 -v verbose: log more messages
25 URL the URL to start in
26 If not provided, the user will be prompted.
42 # pure bash bible trim_string
44 : "${1#"${1%%[![:space:]]*}"}"
45 : "${_%"${_##*[![:space:]]}"}"
50 [[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
53 [[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
62 printf >&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*"
71 if [[ ! "${BOLLUX_URL:+isset}" ]]; 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_DOWNDIR:=.}" # where to save downloads
109 : "${BOLLUX_LOGLEVEL:=3}" # log level
110 : "${BOLLUX_MAXREDIR:=5}" # max redirects
111 : "${BOLLUX_PORT:=1965}" # port number
112 : "${BOLLUX_PROTO:=gemini}" # default protocol
113 : "${BOLLUX_LESSKEY:=/tmp/bollux-lesskey}" # where to store binds
114 : "${BOLLUX_PAGESRC:=/tmp/bollux-src}" # where to save the page source
115 : "${BOLLUX_URL:=}" # start url
117 : "${T_MARGIN:=4}" # left and right margin
118 : "${T_WIDTH:=0}" # width of the viewport -- 0 = get term width
119 # colors -- these will be wrapped in \e[ __ m
120 C_RESET='\e[0m' # reset
121 : "${C_SIGIL:=35}" # sigil (=>, #, ##, ###, *, ```)
122 : "${C_LINK_NUMBER:=1}" # link number
123 : "${C_LINK_TITLE:=4}" # link title
124 : "${C_LINK_URL:=36}" # link URL
125 : "${C_HEADER1:=1;4}" # header 1 formatting
126 : "${C_HEADER2:=1}" # header 2 formatting
127 : "${C_HEADER3:=3}" # header 3 formatting
128 : "${C_LIST:=0}" # list formatting
129 : "${C_PRE:=0}" # preformatted text formatting
133 log x "Thanks for flying $PRGN"
138 printf '\e]2;%s - bollux\007' "$*"
144 read </dev/tty -e -r -p "$prompt> " "$@"
147 blastoff() { # load a url
148 local well_formed=true
149 if [[ "$1" == "-u" ]]; then
155 if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then
156 URL="$(run transform_resource "$BOLLUX_URL" "$1")"
158 [[ "$URL" != *://* ]] && URL="$BOLLUX_PROTO://$URL"
162 server="${server%%/*}"
164 log d "URL='$URL' server='$server'"
166 run request_url "$server" "$BOLLUX_PORT" "$URL" |
167 run handle_response "$URL"
170 transform_resource() { # transform_resource BASE_URL REFERENCE_URL
171 declare -A R B T # reference, base url, target
172 eval "$(run parse_url B "$1")"
173 eval "$(run parse_url R "$2")"
174 # A non-strict parser may ignore a scheme in the reference
175 # if it is identical to the base URI's scheme.
176 if ! "${STRICT:-true}" && [[ "${R[scheme]}" == "${B[scheme]}" ]]; then
180 # basically pseudo-code from spec ported to bash
181 if isdefined "R[scheme]"; then
182 T[scheme]="${R[scheme]}"
183 isdefined "R[authority]" && T[authority]="${R[authority]}"
185 T[path]="$(run remove_dot_segments "${R[path]}")"
186 isdefined "R[query]" && T[query]="${R[query]}"
188 if isdefined "R[authority]"; then
189 T[authority]="${R[authority]}"
190 isdefined "R[authority]" &&
191 T[path]="$(remove_dot_segments "${R[path]}")"
192 isdefined R[query] && T[query]="${R[query]}"
194 if isempty "R[path]"; then
196 if isdefined R[query]; then
197 T[query]="${R[query]}"
199 T[query]="${B[query]}"
202 if [[ "${R[path]}" == /* ]]; then
203 T[path]="$(remove_dot_segments "${R[path]}")"
205 T[path]="$(merge_paths "B[authority]" "${B[path]}" "${R[path]}")"
206 T[path]="$(remove_dot_segments "${T[path]}")"
208 isdefined R[query] && T[query]="${R[query]}"
210 T[authority]="${B[authority]}"
212 T[scheme]="${B[scheme]}"
214 isdefined R[fragment] && T[fragment]="${R[fragment]}"
215 # cf. 5.3 -- recomposition
217 isdefined "T[scheme]" && r="$r${T[scheme]}:"
218 # remove the port from the authority
219 isdefined "T[authority]" && r="$r//${T[authority]%:*}"
221 isdefined T[query] && r="$r?${T[query]}"
222 isdefined T[fragment] && r="$r#${T[fragment]}"
226 merge_paths() { # 5.2.3
227 # shellcheck disable=2034
231 # if R_path is empty, get rid of // in B_path
232 if [[ -z "$R_path" ]]; then
233 printf '%s\n' "${B_path//\/\//\//}"
237 if isdefined "B_authority" && isempty "B_path"; then
238 printf '/%s\n' "${R_path//\/\//\//}"
240 if [[ "$B_path" == */* ]]; then
241 B_path="${B_path%/*}/"
245 printf '%s/%s\n' "${B_path%/}" "${R_path#/}"
249 remove_dot_segments() { # 5.2.4
252 # ^/\.(/|$) - BASH_REMATCH[0]
253 while [[ "$input" ]]; do
254 if [[ "$input" =~ ^\.\.?/ ]]; then
255 input="${input#${BASH_REMATCH[0]}}"
256 elif [[ "$input" =~ ^/\.(/|$) ]]; then
257 input="/${input#${BASH_REMATCH[0]}}"
258 elif [[ "$input" =~ ^/\.\.(/|$) ]]; then
259 input="/${input#${BASH_REMATCH[0]}}"
260 [[ "$output" =~ /?[^/]+$ ]]
261 output="${output%${BASH_REMATCH[0]}}"
262 elif [[ "$input" == . || "$input" == .. ]]; then
265 [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || log debug NOMATCH
266 output="$output${BASH_REMATCH[1]}"
267 input="${BASH_REMATCH[2]}"
270 printf '%s\n' "${output//\/\//\//}"
273 parse_url() { # eval "$(split_url NAME STRING)" => NAME[...]
276 # shopt -u extglob # TODO port re ^ to extglob syntax
277 local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
278 [[ $string =~ $re ]] || return $?
281 local scheme="${BASH_REMATCH[2]}"
282 local authority="${BASH_REMATCH[4]}"
283 local path="${BASH_REMATCH[5]}"
284 local query="${BASH_REMATCH[7]}"
285 local fragment="${BASH_REMATCH[9]}"
287 for c in scheme authority query fragment; do
289 run printf '%s[%s]=%q\n' "$name" "$c" "${!c}"
291 # unclear if the path is always set even if empty but it looks that way
292 run printf '%s[path]=%q\n' "$name" "$path"
295 # is a NAME defined ('set' in bash)?
296 isdefined() { [[ "${!1+x}" ]]; } # isdefined NAME
297 # is a NAME defined AND empty?
298 isempty() { [[ ! "${!1-x}" ]]; } # isempty NAME
305 ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port")
306 ssl_cmd+=(-servername "$server") # SNI
307 run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null
311 local URL="$1" code meta
313 while read -r -d $'\r' hdr; do
314 code="$(gawk '{print $1}' <<<"$hdr")"
316 gawk '{for(i=2;i<=NF;i++)printf "%s ",$i;printf "\n"}' <<<"$hdr"
321 log x "[$code] $meta"
326 run history_append "$URL"
327 run prompt "$meta" QUERY
328 # shellcheck disable=2153
329 run blastoff "?$QUERY"
333 run history_append "$URL"
338 if ((REDIRECTS > BOLLUX_MAXREDIR)); then
339 die $((100 + code)) "Too many redirects!"
345 die "$((100 + code))" "$code"
349 die "$((100 + code))" "$code"
353 die "$((100 + code))" "$code"
356 [[ -z "${code-}" ]] && die 100 "Empty response code."
357 die "$((100 + code)) Unknown response code: $code."
366 IFS=$'\n' read -d "" -ra hdr <<<"${1//;/$'\n'}"
368 mime="$(trim "${hdr[0],,}")"
369 for ((i = 1; i <= "${#hdr[@]}"; i++)); do
370 h="$(trim "${hdr[$i]}")"
372 charset=*) charset="${h#charset=}" ;;
376 [[ -z "$mime" ]] && mime="text/gemini"
377 if [[ -z "$charset" ]]; then
381 log debug "mime='$mime'; charset='$charset'"
385 set_title "$BOLLUX_URL"
388 [[ -r "$BOLLUX_LESSKEY" ]] || mklesskey "$BOLLUX_LESSKEY"
389 } && less_cmd+=(-k "$BOLLUX_LESSKEY")
392 -PM'o\:open, g\:goto, r\:refresh$'
397 if declare -F | grep -q "$submime"; then
398 log d "typeset_$submime"
401 iconv -f "${charset^^}" -t "UTF-8" |
402 tee "$BOLLUX_PAGESRC" |
403 run "typeset_$submime" |
404 run "${less_cmd[@]}" && bollux_quit
405 } || run handle_keypress "$?"
410 iconv -f "${charset^^}" -t "UTF-8" |
411 tee "$BOLLUX_PAGESRC" |
412 run "${less_cmd[@]}" && bollux_quit
413 } || run handle_keypress "$?"
416 *) run download "$BOLLUX_URL" ;;
421 lesskey -o "$1" - <<-END
423 o quit 0 # 48 open a link
424 g quit 1 # 49 goto a url
426 ] quit 3 # 51 forward
427 r quit 4 # 52 re-request / download
429 \40 forw-screen-force
435 while IFS= read -r; do
436 printf '%s\n' "${REPLY//$'\r'?($'\n')/}"
443 local ln=0 # link number
445 if ((T_WIDTH == 0)); then
446 shopt -s checkwinsize
450 ) # XXX this doesn't work!?
451 log d "LINES=$LINES; COLUMNS=$COLUMNS"
454 WIDTH=$((T_WIDTH - T_MARGIN))
455 ((WIDTH < 0)) && WIDTH=80 # default if dumb
456 S_MARGIN=$((T_MARGIN - 1)) # spacing
458 log d "T_WIDTH=$T_WIDTH"
461 while IFS= read -r; do
473 gemini_link "$REPLY" $pre "$ln"
475 \#*) gemini_header "$REPLY" $pre ;;
477 if [[ "$REPLY" =~ ^\*[[:space:]]+ ]]; then
478 gemini_list "$REPLY" $pre
480 gemini_text "$REPLY" $pre
483 *) gemini_text "$REPLY" $pre ;;
489 local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)"
490 local s t a l # sigil, text, annotation(url), line
491 if ! ${2-false} && [[ "$1" =~ $re ]]; then
492 s="${BASH_REMATCH[1]}"
493 a="${BASH_REMATCH[2]}"
494 t="${BASH_REMATCH[3]}"
495 if [[ -z "$t" ]]; then
500 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
501 printf -v l "\e[${C_LINK_NUMBER}m[%d]${C_RESET} \
502 \e[${C_LINK_TITLE}m%s${C_RESET} \
503 \e[${C_LINK_URL}m%s${C_RESET}\n" \
505 fold_line "$WIDTH" "$l"
512 local re="^(#+)[[:blank:]]*(.*)"
513 local s t a l # sigil, text, annotation(lvl), line
514 if ! ${2-false} && [[ "$1" =~ $re ]]; then
515 s="${BASH_REMATCH[1]}"
516 a="${#BASH_REMATCH[1]}"
517 t="${BASH_REMATCH[2]}"
519 hdrfmt="$(eval echo "\$C_HEADER$a")"
521 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
522 printf -v l "\e[${hdrfmt}m%s${C_RESET}\n" "$t"
523 fold_line "$WIDTH" "$l"
530 local re="^(\*)[[:blank:]]*(.*)"
531 local s t a l # sigil, text, annotation(n/a), line
532 if ! ${2-false} && [[ "$1" =~ $re ]]; then
533 s="${BASH_REMATCH[1]}"
534 t="${BASH_REMATCH[2]}"
536 printf "\e[${C_SIGIL}m%${S_MARGIN}s " "$s"
537 printf -v l "\e[${C_LIST}m%s${C_RESET}\n" "$t"
538 fold_line "$WIDTH" "$l"
545 if ! ${2-false}; then
546 printf "%${S_MARGIN}s " ' '
547 fold_line "$WIDTH" "$1"
554 printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```'
555 printf "\e[${C_PRE}m%s${C_RESET}\n" "$1"
558 fold_line() { # fold_line WIDTH TEXT
560 local margin="${2%%[![:space:]]*}"
561 if [[ "$margin" ]]; then
567 # shellcheck disable=2086
568 set -- $2 # TODO: is this the best way?
571 plain="${word//$'\x1b'\[*([0-9;])m/}"
572 wl=$((${#plain} + 1))
573 if (((ll + wl) >= width)); then
574 printf "\n%${margin}s" ' '
586 48) # o - open a link -- show a menu of links on the page
587 run select_url "$BOLLUX_PAGESRC"
589 49) # g - goto a url -- input a new url
591 run blastoff -u "$URL"
593 50) # [ - back in the history
594 run history_back || {
596 run blastoff "$BOLLUX_URL"
599 51) # ] - forward in the history
600 run history_forward || {
602 run blastoff "$BOLLUX_URL"
605 52) # r - re-request the current resource
606 run blastoff "$BOLLUX_URL"
608 *) # 53-57 -- still available for binding
614 run mapfile -t < <(extract_links <"$1")
615 select u in "${MAPFILE[@]}"; do
619 run blastoff "$(gawk '{print $1}' <<<"$u")" && break
626 sub(/=>[[:space:]]*/,"")
629 for (i=2;i<=NF;i++) {
630 rest=rest (rest?" ":"")$i
632 printf "%s (\033[34m%s\033[0m)\n", $1, rest
641 log x "Downloading: '$BOLLUX_URL' => '$tn'..."
642 dd status=progress >"$tn"
643 fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}"
644 if [[ -f "$fn" ]]; then
646 elif mv "$tn" "$fn"; then
649 log error "Error saving '$fn': downloaded to '$tn'."
654 declare -a HISTORY # history is kept in an array
655 HN=0 # position of history in the array
658 history_append() { # history_append URL
660 HISTORY[$HN]="$BOLLUX_URL"
661 log d "HN=$HN HISTORY: ${HISTORY[*]}"
670 log e "Beginning of history."
673 blastoff "${HISTORY[$HN]}"
677 if ((HN >= ${#HISTORY[@]})); then
679 log e "End of history."
682 blastoff "${HISTORY[$HN]}"
685 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
688 BOLLUX_LOGLEVEL=DEBUG