#!/usr/bin/env bash # bollux: a bash gemini client # Author: Case Duckworth # License: MIT # Version: 0.2.2 # Program information PRGN="${0##*/}" VRSN=0.2.2 # State REDIRECTS=0 set -f bollux_usage() { cat <&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*" } # main entry point bollux() { run bollux_config run bollux_args "$@" run history_init if [[ ! "${BOLLUX_URL:+isset}" ]]; then run prompt GO BOLLUX_URL fi run blastoff "$BOLLUX_URL" } bollux_args() { while getopts :hvq OPT; do case "$OPT" in h) bollux_usage exit ;; v) BOLLUX_LOGLEVEL=DEBUG ;; q) BOLLUX_LOGLEVEL=QUIET ;; :) die 1 "Option -$OPTARG requires an argument" ;; *) die 1 "Unknown option: -$OPTARG" ;; esac done shift $((OPTIND - 1)) if (($# == 1)); then BOLLUX_URL="$1" fi } bollux_config() { : "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/bollux.conf}" if [ -f "$BOLLUX_CONFIG" ]; then # shellcheck disable=1090 . "$BOLLUX_CONFIG" else log debug "Can't load config file '$BOLLUX_CONFIG'." fi ## behavior : "${BOLLUX_DOWNDIR:=.}" # where to save downloads : "${BOLLUX_LOGLEVEL:=3}" # log level : "${BOLLUX_MAXREDIR:=5}" # max redirects : "${BOLLUX_PORT:=1965}" # port number : "${BOLLUX_PROTO:=gemini}" # default protocol : "${BOLLUX_LESSKEY:=/tmp/bollux-lesskey}" # where to store binds : "${BOLLUX_PAGESRC:=/tmp/bollux-src}" # where to save the page source : "${BOLLUX_URL:=}" # start url ## typesetting : "${T_MARGIN:=4}" # left and right margin : "${T_WIDTH:=0}" # width of the viewport -- 0 = get term width # colors -- these will be wrapped in \e[ __ m C_RESET='\e[0m' # reset : "${C_SIGIL:=35}" # sigil (=>, #, ##, ###, *, ```) : "${C_LINK_NUMBER:=1}" # link number : "${C_LINK_TITLE:=4}" # link title : "${C_LINK_URL:=36}" # link URL : "${C_HEADER1:=1;4}" # header 1 formatting : "${C_HEADER2:=1}" # header 2 formatting : "${C_HEADER3:=3}" # header 3 formatting : "${C_LIST:=0}" # list formatting : "${C_PRE:=0}" # preformatted text formatting } bollux_quit() { log x "Thanks for flying $PRGN" exit } set_title() { printf '\e]2;%s - bollux\007' "$*" } prompt() { prompt="$1" shift read " "$@" } blastoff() { # load a url local well_formed=true if [[ "$1" == "-u" ]]; then well_formed=false shift fi URL="$1" if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then URL="$(run transform_resource "$BOLLUX_URL" "$1")" fi [[ "$URL" != *://* ]] && URL="$BOLLUX_PROTO://$URL" URL="$(trim "$URL")" server="${URL#*://}" server="${server%%/*}" log d "URL='$URL' server='$server'" run request_url "$server" "$BOLLUX_PORT" "$URL" | run handle_response "$URL" } transform_resource() { # transform_resource BASE_URL REFERENCE_URL declare -A R B T # reference, base url, target eval "$(run parse_url B "$1")" eval "$(run parse_url R "$2")" # A non-strict parser may ignore a scheme in the reference # if it is identical to the base URI's scheme. if ! "${STRICT:-true}" && [[ "${R[scheme]}" == "${B[scheme]}" ]]; then unset "${R[scheme]}" fi # basically pseudo-code from spec ported to bash if isdefined "R[scheme]"; then T[scheme]="${R[scheme]}" isdefined "R[authority]" && T[authority]="${R[authority]}" isdefined R[path] && T[path]="$(run remove_dot_segments "${R[path]}")" isdefined "R[query]" && T[query]="${R[query]}" else if isdefined "R[authority]"; then T[authority]="${R[authority]}" isdefined "R[authority]" && T[path]="$(remove_dot_segments "${R[path]}")" isdefined R[query] && T[query]="${R[query]}" else if isempty "R[path]"; then T[path]="${B[path]}" if isdefined R[query]; then T[query]="${R[query]}" else T[query]="${B[query]}" fi else if [[ "${R[path]}" == /* ]]; then T[path]="$(remove_dot_segments "${R[path]}")" else T[path]="$(merge_paths "B[authority]" "${B[path]}" "${R[path]}")" T[path]="$(remove_dot_segments "${T[path]}")" fi isdefined R[query] && T[query]="${R[query]}" fi T[authority]="${B[authority]}" fi T[scheme]="${B[scheme]}" fi isdefined R[fragment] && T[fragment]="${R[fragment]}" # cf. 5.3 -- recomposition local r="" isdefined "T[scheme]" && r="$r${T[scheme]}:" # remove the port from the authority isdefined "T[authority]" && r="$r//${T[authority]%:*}" r="$r${T[path]}" isdefined T[query] && r="$r?${T[query]}" isdefined T[fragment] && r="$r#${T[fragment]}" printf '%s\n' "$r" } merge_paths() { # 5.2.3 # shellcheck disable=2034 B_authority="$1" B_path="$2" R_path="$3" # if R_path is empty, get rid of // in B_path if [[ -z "$R_path" ]]; then printf '%s\n' "${B_path//\/\//\//}" return fi if isdefined "B_authority" && isempty "B_path"; then printf '/%s\n' "${R_path//\/\//\//}" else if [[ "$B_path" == */* ]]; then B_path="${B_path%/*}/" else B_path="" fi printf '%s/%s\n' "${B_path%/}" "${R_path#/}" fi } remove_dot_segments() { # 5.2.4 local input="$1" local output= # ^/\.(/|$) - BASH_REMATCH[0] while [[ "$input" ]]; do if [[ "$input" =~ ^\.\.?/ ]]; then input="${input#${BASH_REMATCH[0]}}" elif [[ "$input" =~ ^/\.(/|$) ]]; then input="/${input#${BASH_REMATCH[0]}}" elif [[ "$input" =~ ^/\.\.(/|$) ]]; then input="/${input#${BASH_REMATCH[0]}}" [[ "$output" =~ /?[^/]+$ ]] output="${output%${BASH_REMATCH[0]}}" elif [[ "$input" == . || "$input" == .. ]]; then input= else [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || log debug NOMATCH output="$output${BASH_REMATCH[1]}" input="${BASH_REMATCH[2]}" fi done printf '%s\n' "${output//\/\//\//}" } parse_url() { # eval "$(split_url NAME STRING)" => NAME[...] local name="$1" local string="$2" # shopt -u extglob # TODO port re ^ to extglob syntax local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?' [[ $string =~ $re ]] || return $? # shopt -s extglob local scheme="${BASH_REMATCH[2]}" local authority="${BASH_REMATCH[4]}" local path="${BASH_REMATCH[5]}" local query="${BASH_REMATCH[7]}" local fragment="${BASH_REMATCH[9]}" for c in scheme authority query fragment; do [[ "${!c}" ]] && run printf '%s[%s]=%q\n' "$name" "$c" "${!c}" done # unclear if the path is always set even if empty but it looks that way run printf '%s[path]=%q\n' "$name" "$path" } # is a NAME defined ('set' in bash)? isdefined() { [[ "${!1+x}" ]]; } # isdefined NAME # is a NAME defined AND empty? isempty() { [[ ! "${!1-x}" ]]; } # isempty NAME request_url() { local server="$1" local port="$2" local url="$3" ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port") ssl_cmd+=(-servername "$server") # SNI run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null } handle_response() { local URL="$1" code meta while read -r -d $'\r' hdr; do code="$(gawk '{print $1}' <<<"$hdr")" meta="$( gawk '{for(i=2;i<=NF;i++)printf "%s ",$i;printf "\n"}' <<<"$hdr" )" break done log x "[$code] $meta" case "$code" in 1*) REDIRECTS=0 run history_append "$URL" run prompt "$meta" QUERY # shellcheck disable=2153 run blastoff "?$QUERY" ;; 2*) REDIRECTS=0 run history_append "$URL" run display "$meta" ;; 3*) ((REDIRECTS += 1)) if ((REDIRECTS > BOLLUX_MAXREDIR)); then die $((100 + code)) "Too many redirects!" fi run blastoff "$meta" ;; 4*) REDIRECTS=0 die "$((100 + code))" "$code" ;; 5*) REDIRECTS=0 die "$((100 + code))" "$code" ;; 6*) REDIRECTS=0 die "$((100 + code))" "$code" ;; *) [[ -z "${code-}" ]] && die 100 "Empty response code." die "$((100 + code)) Unknown response code: $code." ;; esac } display() { # split header line local -a hdr local i IFS=$'\n' read -d "" -ra hdr <<<"${1//;/$'\n'}" mime="$(trim "${hdr[0],,}")" for ((i = 1; i <= "${#hdr[@]}"; i++)); do h="$(trim "${hdr[$i]}")" case "$h" in charset=*) charset="${h#charset=}" ;; esac done [[ -z "$mime" ]] && mime="text/gemini" if [[ -z "$charset" ]]; then charset="utf-8" fi log debug "mime='$mime'; charset='$charset'" case "$mime" in text/*) set_title "$BOLLUX_URL" less_cmd=(less -R) { [[ -r "$BOLLUX_LESSKEY" ]] || mklesskey "$BOLLUX_LESSKEY" } && less_cmd+=(-k "$BOLLUX_LESSKEY") less_cmd+=( -Pm'bollux$' -PM'o\:open, g\:goto, r\:refresh$' -M ) submime="${mime#*/}" if declare -F | grep -q "$submime"; then log d "typeset_$submime" { normalize_crlf | iconv -f "${charset^^}" -t "UTF-8" | tee "$BOLLUX_PAGESRC" | run "typeset_$submime" | run "${less_cmd[@]}" && bollux_quit } || run handle_keypress "$?" else log "cat" { normalize_crlf | iconv -f "${charset^^}" -t "UTF-8" | tee "$BOLLUX_PAGESRC" | run "${less_cmd[@]}" && bollux_quit } || run handle_keypress "$?" fi ;; *) run download "$BOLLUX_URL" ;; esac } mklesskey() { lesskey -o "$1" - <<-END #command o quit 0 # 48 open a link g quit 1 # 49 goto a url [ quit 2 # 50 back ] quit 3 # 51 forward r quit 4 # 52 re-request / download # other keybinds \40 forw-screen-force END } normalize_crlf() { shopt -s extglob while IFS= read -r; do printf '%s\n' "${REPLY//$'\r'?($'\n')/}" done shopt -u extglob } typeset_gemini() { local pre=false local ln=0 # link number if ((T_WIDTH == 0)); then shopt -s checkwinsize ( : : ) # XXX this doesn't work!? log d "LINES=$LINES; COLUMNS=$COLUMNS" T_WIDTH=$COLUMNS fi WIDTH=$((T_WIDTH - T_MARGIN)) ((WIDTH < 0)) && WIDTH=80 # default if dumb S_MARGIN=$((T_MARGIN - 1)) # spacing log d "T_WIDTH=$T_WIDTH" log d "WIDTH=$WIDTH" while IFS= read -r; do case "$REPLY" in '```'*) if $pre; then pre=false else pre=true fi continue ;; =\>*) : $((ln += 1)) gemini_link "$REPLY" $pre "$ln" ;; \#*) gemini_header "$REPLY" $pre ;; \**) if [[ "$REPLY" =~ ^\*[[:space:]]+ ]]; then gemini_list "$REPLY" $pre else gemini_text "$REPLY" $pre fi ;; *) gemini_text "$REPLY" $pre ;; esac done } gemini_link() { local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)" local s t a l # sigil, text, annotation(url), line if ! ${2-false} && [[ "$1" =~ $re ]]; then s="${BASH_REMATCH[1]}" a="${BASH_REMATCH[2]}" t="${BASH_REMATCH[3]}" if [[ -z "$t" ]]; then t="$a" a= fi printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s" printf -v l "\e[${C_LINK_NUMBER}m[%d]${C_RESET} \ \e[${C_LINK_TITLE}m%s${C_RESET} \ \e[${C_LINK_URL}m%s${C_RESET}\n" \ "$3" "$t" "$a" fold_line "$WIDTH" "$l" else gemini_pre "$1" fi } gemini_header() { local re="^(#+)[[:blank:]]*(.*)" local s t a l # sigil, text, annotation(lvl), line if ! ${2-false} && [[ "$1" =~ $re ]]; then s="${BASH_REMATCH[1]}" a="${#BASH_REMATCH[1]}" t="${BASH_REMATCH[2]}" local hdrfmt hdrfmt="$(eval echo "\$C_HEADER$a")" printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s" printf -v l "\e[${hdrfmt}m%s${C_RESET}\n" "$t" fold_line "$WIDTH" "$l" else gemini_pre "$1" fi } gemini_list() { local re="^(\*)[[:blank:]]*(.*)" local s t a l # sigil, text, annotation(n/a), line if ! ${2-false} && [[ "$1" =~ $re ]]; then s="${BASH_REMATCH[1]}" t="${BASH_REMATCH[2]}" printf "\e[${C_SIGIL}m%${S_MARGIN}s " "$s" printf -v l "\e[${C_LIST}m%s${C_RESET}\n" "$t" fold_line "$WIDTH" "$l" else gemini_pre "$1" fi } gemini_text() { if ! ${2-false}; then printf "%${S_MARGIN}s " ' ' fold_line "$WIDTH" "$1" else gemini_pre "$1" fi } gemini_pre() { printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```' printf "\e[${C_PRE}m%s${C_RESET}\n" "$1" } fold_line() { # fold_line WIDTH TEXT local width="$1" local margin="${2%%[![:space:]]*}" if [[ "$margin" ]]; then margin="${#margin}" else margin="$T_MARGIN" fi local ll=0 wl plain # shellcheck disable=2086 set -- $2 # TODO: is this the best way? for word; do plain="${word//$'\x1b'\[*([0-9;])m/}" wl=$((${#plain} + 1)) if (((ll + wl) >= width)); then printf "\n%${margin}s" ' ' ll=$wl else ll=$((ll + wl)) fi printf '%s ' "$word" done printf '\n' } handle_keypress() { case "$1" in 48) # o - open a link -- show a menu of links on the page run select_url "$BOLLUX_PAGESRC" ;; 49) # g - goto a url -- input a new url prompt GO URL run blastoff -u "$URL" ;; 50) # [ - back in the history run history_back || { sleep 0.5 run blastoff "$BOLLUX_URL" } ;; 51) # ] - forward in the history run history_forward || { sleep 0.5 run blastoff "$BOLLUX_URL" } ;; 52) # r - re-request the current resource run blastoff "$BOLLUX_URL" ;; *) # 53-57 -- still available for binding ;; esac } select_url() { run mapfile -t < <(extract_links <"$1") select u in "${MAPFILE[@]}"; do case "$REPLY" in q) bollux_quit ;; esac run blastoff "$(gawk '{print $1}' <<<"$u")" && break done / { sub(/=>[[:space:]]*/,"") if ($2) { rest="" for (i=2;i<=NF;i++) { rest=rest (rest?" ":"")$i } printf "%s (\033[34m%s\033[0m)\n", $1, rest } else { printf "%s\n", $1 } }' } download() { tn="$(mktemp)" log x "Downloading: '$BOLLUX_URL' => '$tn'..." dd status=progress >"$tn" fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}" if [[ -f "$fn" ]]; then log x "Saved '$tn'." elif mv "$tn" "$fn"; then log x "Saved '$fn'." else log error "Error saving '$fn': downloaded to '$tn'." fi } history_init() { declare -a HISTORY # history is kept in an array HN=0 # position of history in the array } history_append() { # history_append URL BOLLUX_URL="$1" HISTORY[$HN]="$BOLLUX_URL" log d "HN=$HN HISTORY: ${HISTORY[*]}" ((HN += 1)) } history_back() { log d "HN=$HN" ((HN -= 2)) if ((HN < 0)); then HN=0 log e "Beginning of history." return 1 fi blastoff "${HISTORY[$HN]}" } history_forward() { log d "HN=$HN" if ((HN >= ${#HISTORY[@]})); then HN="${#HISTORY[@]}" log e "End of history." return 1 fi blastoff "${HISTORY[$HN]}" } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then run bollux "$@" else BOLLUX_LOGLEVEL=DEBUG fi