2 # bollux: a bash gemini client
3 # Author: Case Duckworth
17 $PRGN (v. $VRSN): a bash gemini client
22 -h show this help and exit
23 -q be quiet: log no messages
24 -v verbose: log more messages
26 URL the URL to start in
27 If not provided, the user will be prompted.
43 # pure bash bible trim_string
45 : "${1#"${1%%[![:space:]]*}"}"
46 : "${_%"${_##*[![:space:]]}"}"
51 [[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
54 [[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
63 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/config}"
100 if [ -f "$BOLLUX_CONFIG" ]; then
101 # shellcheck disable=1090
104 log debug "Can't load config file '$BOLLUX_CONFIG'."
107 : "${BOLLUX_DOWNDIR:=.}" # where to save downloads
108 : "${BOLLUX_LOGLEVEL:=3}" # log level
109 : "${BOLLUX_MAXREDIR:=5}" # max redirects
110 : "${BOLLUX_PORT:=1965}" # port number
111 : "${BOLLUX_PROTO:=gemini}" # default protocol
112 : "${BOLLUX_LESSKEY:=/tmp/bollux-lesskey}" # where to store binds
113 : "${BOLLUX_PAGESRC:=/tmp/bollux-src}" # where to save the page source
114 : "${BOLLUX_URL:=}" # start url
120 read </dev/tty -e -r -p "$prompt> " "$@"
123 blastoff() { # load a url
124 local well_formed=true
125 if [[ "$1" == "-u" ]]; then
131 if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then
132 URL="$(run transform_resource "$BOLLUX_URL" "$1")"
134 [[ "$URL" != *://* ]] && URL="$BOLLUX_PROTO://$URL"
138 server="${server%%/*}"
140 log d "URL='$URL' server='$server'"
142 run request_url "$server" "$BOLLUX_PORT" "$URL" |
143 run handle_response "$URL"
146 transform_resource() { # transform_resource BASE_URL REFERENCE_URL
147 declare -A R B T # reference, base url, target
148 eval "$(run parse_url B "$1")"
149 eval "$(run parse_url R "$2")"
150 # A non-strict parser may ignore a scheme in the reference
151 # if it is identical to the base URI's scheme.
152 if ! "${STRICT:-true}" && [[ "${R[scheme]}" == "${B[scheme]}" ]]; then
156 # basically pseudo-code from spec ported to bash
157 if isdefined "R[scheme]"; then
158 T[scheme]="${R[scheme]}"
159 isdefined "R[authority]" && T[authority]="${R[authority]}"
161 T[path]="$(run remove_dot_segments "${R[path]}")"
162 isdefined "R[query]" && T[query]="${R[query]}"
164 if isdefined "R[authority]"; then
165 T[authority]="${R[authority]}"
166 isdefined "R[authority]" &&
167 T[path]="$(remove_dot_segments "${R[path]}")"
168 isdefined R[query] && T[query]="${R[query]}"
170 if isempty "R[path]"; then
172 if isdefined R[query]; then
173 T[query]="${R[query]}"
175 T[query]="${B[query]}"
178 if [[ "${R[path]}" == /* ]]; then
179 T[path]="$(remove_dot_segments "${R[path]}")"
181 T[path]="$(merge_paths "B[authority]" "${B[path]}" "${R[path]}")"
182 T[path]="$(remove_dot_segments "${T[path]}")"
184 isdefined R[query] && T[query]="${R[query]}"
186 T[authority]="${B[authority]}"
188 T[scheme]="${B[scheme]}"
190 isdefined R[fragment] && T[fragment]="${R[fragment]}"
191 # cf. 5.3 -- recomposition
193 isdefined "T[scheme]" && r="$r${T[scheme]}:"
194 isdefined "T[authority]" && r="$r//${T[authority]}"
196 isdefined T[query] && r="$r?${T[query]}"
197 isdefined T[fragment] && r="$r#${T[fragment]}"
201 merge_paths() { # 5.2.3
202 # shellcheck disable=2034
206 # if R_path is empty, get rid of // in B_path
207 if [[ -z "$R_path" ]]; then
208 printf '%s\n' "${B_path//\/\//\//}"
212 if isdefined "B_authority" && isempty "B_path"; then
213 printf '/%s\n' "${R_path//\/\//\//}"
215 if [[ "$B_path" == */* ]]; then
216 B_path="${B_path%/*}/"
220 printf '%s/%s\n' "${B_path%/}" "${R_path#/}"
224 remove_dot_segments() { # 5.2.4
227 # ^/\.(/|$) - BASH_REMATCH[0]
228 while [[ "$input" ]]; do
229 if [[ "$input" =~ ^\.\.?/ ]]; then
230 input="${input#${BASH_REMATCH[0]}}"
231 elif [[ "$input" =~ ^/\.(/|$) ]]; then
232 input="/${input#${BASH_REMATCH[0]}}"
233 elif [[ "$input" =~ ^/\.\.(/|$) ]]; then
234 input="/${input#${BASH_REMATCH[0]}}"
235 [[ "$output" =~ /?[^/]+$ ]]
236 output="${output%${BASH_REMATCH[0]}}"
237 elif [[ "$input" == . || "$input" == .. ]]; then
240 [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || echo NOMATCH >&2
241 output="$output${BASH_REMATCH[1]}"
242 input="${BASH_REMATCH[2]}"
245 printf '%s\n' "${output//\/\//\//}"
248 parse_url() { # eval "$(split_url NAME STRING)" => NAME[...]
251 # shopt -u extglob # TODO port re ^ to extglob syntax
252 local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
253 [[ $string =~ $re ]] || return $?
256 local scheme="${BASH_REMATCH[2]}"
257 local authority="${BASH_REMATCH[4]}"
258 local path="${BASH_REMATCH[5]}"
259 local query="${BASH_REMATCH[7]}"
260 local fragment="${BASH_REMATCH[9]}"
262 for c in scheme authority query fragment; do
264 run printf '%s[%s]=%q\n' "$name" "$c" "${!c}"
266 # unclear if the path is always set even if empty but it looks that way
267 run printf '%s[path]=%q\n' "$name" "$path"
270 # is a NAME defined ('set' in bash)?
271 isdefined() { [[ "${!1+x}" ]]; } # isdefined NAME
272 # is a NAME defined AND empty?
273 isempty() { [[ ! "${!1-x}" ]]; } # isempty NAME
280 ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port")
281 ssl_cmd+=(-servername "$server") # SNI
282 run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null
286 local url="$1" code meta
288 while read -r -d $'\r' hdr; do
289 code="$(gawk '{print $1}' <<<"$hdr")"
291 gawk '{for(i=2;i<=NF;i++)printf "%s ",$i;printf "\n"}' <<<"$hdr"
296 log x "[$code] $meta"
302 run prompt "$meta" QUERY
303 # shellcheck disable=2153
304 run blastoff "?$QUERY"
313 if ((REDIRECTS > BOLLUX_MAXREDIR)); then
314 die $((100 + code)) "Too many redirects!"
321 die "$((100 + code))" "$code"
325 die "$((100 + code))" "$code"
329 die "$((100 + code))" "$code"
331 *) die "$((100 + code)) Unknown response code: $code." ;;
342 log d "$mime $charset"
344 *) mime="$(trim "$1")" ;;
347 [[ -z "$mime" ]] && mime="text/gemini"
348 if [[ -z "$charset" ]]; then
351 charset="${charset#charset=}"
354 log debug "mime=$mime; charset=$charset"
360 [[ -r "$BOLLUX_LESSKEY" ]] || mklesskey "$BOLLUX_LESSKEY"
361 } && less_cmd+=(-k "$BOLLUX_LESSKEY")
364 -PM'o\:open, g\:goto, r\:refresh$'
369 if declare -F | grep -q "$submime"; then
370 log d "typeset_$submime"
373 tee "$BOLLUX_PAGESRC" |
374 run "typeset_$submime" |
376 } || run handle_keypress "$?"
381 tee "$BOLLUX_PAGESRC" |
383 } || run handle_keypress "$?"
386 *) run download "$BOLLUX_URL" ;;
391 lesskey -o "$1" - <<-END
393 o quit 0 # 48 open a link
394 g quit 1 # 49 goto a url
396 ] quit 3 # 51 forward
397 r quit 4 # 52 re-request / download
402 while read -r line; do
403 printf '%s\n' "${line//$'\r'?($'\n')/}"
411 margin = margin ? margin : 4
435 mark = substr($0, RSTART, RLENGTH)
436 sub(/#+[[:space:]]*/, "", $0)
440 } else if (level == 2) {
448 sub(/=>[[:space:]]*/, "", $0)
451 for (w = 2; w <= NF; w++) {
452 text = text (text ? " " : "") $w
454 fmt = lns "[" (++ln) "]" res " " lts "%s" res "\t" lus "%s" res
458 sub(/\*[[:space:]]*/, "", $0)
462 mark = mark ? mark : mark
463 fmt = fmt ? fmt : "%s"
464 text = text ? text : $0
465 desc = desc ? desc : ""
466 printf ms "%" (margin-1) "s " res fmt "\n", mark, text, desc
467 mark = fmt = text = desc = ""
474 48) # o - open a link -- show a menu of links on the page
475 run select_url "$BOLLUX_PAGESRC"
477 49) # g - goto a url -- input a new url
479 run blastoff -u "$URL"
481 50) # [ - back in the history
484 51) # ] - forward in the history
487 52) # r - re-request the current resource
488 run blastoff "$BOLLUX_URL"
490 *) # 53-57 -- still available for binding
496 run mapfile -t < <(extract_links <"$1")
497 select u in "${MAPFILE[@]}"; do
498 run blastoff "$(gawk '{print $1}' <<<"$u")" && break
505 sub(/=>[[:space:]]*/,"")
507 printf "%s (\033[34m%s\033[0m)\n", $1, $2
515 log x "Downloading: '$BOLLUX_URL' => '$tn'..."
516 dd status=progress >"$tn"
517 fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}"
518 if [[ -f "$fn" ]]; then
520 elif mv "$tn" "$fn"; then
523 log error "Error saving '$fn': downloaded to '$tn'."
527 history_back() { log error "Not implemented."; }
528 history_forward() { log error "Not implemented."; }
530 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
533 BOLLUX_LOGLEVEL=DEBUG