#!/usr/bin/env bash # bollux: a bash gemini client # Author: Case Duckworth # License: MIT # Version: 0.4.0 # Program information PRGN="${0##*/}" VRSN=0.4.0 bollux_usage() { cat <&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*" } # main entry point bollux() { run bollux_config # TODO: figure out better config method run bollux_args "$@" # and argument parsing run bollux_init if [[ ! "${BOLLUX_URL:+x}" ]]; 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_TIMEOUT:=30}" # connection timeout : "${BOLLUX_MAXREDIR:=5}" # max redirects : "${BOLLUX_PORT:=1965}" # port number : "${BOLLUX_PROTO:=gemini}" # default protocol : "${BOLLUX_URL:=}" # start url : "${BOLLUX_BYEMSG:=See You Space Cowboy...}" # bye message ## files : "${BOLLUX_DATADIR:=${XDG_DATA_DIR:-$HOME/.local/share}/bollux}" : "${BOLLUX_DOWNDIR:=.}" # where to save downloads : "${BOLLUX_LESSKEY:=$BOLLUX_DATADIR/lesskey}" # where to store binds : "${BOLLUX_PAGESRC:=$BOLLUX_DATADIR/pagesrc}" # where to save the source BOLLUX_HISTFILE="$BOLLUX_DATADIR/history" # where to save the history ## 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 "$BOLLUX_BYEMSG" exit } set_title() { printf '\e]2;%s - bollux\007' "$*" } prompt() { # prompt [-u] PROMPT [READ_ARGS...] local read_cmd=(read -e -r) if [[ "$1" == "-u" ]]; then read_cmd+=(-i "$BOLLUX_URL") shift fi local prompt="$1" shift read_cmd+=(-p "$prompt> ") "${read_cmd[@]}" /dev/null; then run "${proto}_request" "$url" else log d "No request handler for '$proto'; trying gemini" run gemini_request "$url" fi } | run normalize | { if declare -Fp "${proto}_response" >/dev/null; then run "${proto}_response" "$url" else log d "No response handler for '$proto'; handling raw response" raw_response fi } } transform_resource() { # transform_resource BASE_URL REFERENCE_URL local -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 local B_authority="$1" local B_path="$2" local 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 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 # split a string -- see pure bash bible split() { # split STRING DELIMITER local -a arr IFS=$'\n' read -d "" -ra arr <<<"${1//$2/$'\n'}" printf '%s\n' "${arr[@]}" } # GEMINI # https://gemini.circumlunar.space/docs/spec-spec.txt gemini_request() { local url port server local ssl_cmd url="$1" port=1965 server="${url#*://}" server="${server%%/*}" ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port") # disable old TLS/SSL versions (thanks makeworld!) ssl_cmd+=(-no_ssl3 -no_tls1 -no_tls1_1) # always try to connect with TLS v1.3 first run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null } gemini_response() { local url code meta local title url="$1" # we need a loop here so it waits for the first line while read -t "$BOLLUX_TIMEOUT" -r code meta || { (($? > 128)) && die 99 "Timeout."; }; do break done log d "[$code] $meta" case "$code" in 1*) # input REDIRECTS=0 run prompt "$meta" run blastoff "?$REPLY" ;; 2*) # OK REDIRECTS=0 # read ahead to find a title local pretitle while read -r; do pretitle="$pretitle$REPLY"$'\n' if [[ "$REPLY" =~ ^#[[:space:]]*(.*) ]]; then title="${BASH_REMATCH[1]}" break fi done run history_append "$url" "${title:-}" # read the body out and pipe it to display { printf '%s' "$pretitle" passthru } | run display "$meta" "${title:-}" ;; 3*) # redirect ((REDIRECTS += 1)) if ((REDIRECTS > BOLLUX_MAXREDIR)); then die $((100 + code)) "Too many redirects!" fi run blastoff "$meta" # TODO: confirm redirect ;; 4*) # temporary error REDIRECTS=0 die "$((100 + code))" "Temporary error [$code]: $meta" ;; 5*) # permanent error REDIRECTS=0 die "$((100 + code))" "Permanent error [$code]: $meta" ;; 6*) # certificate error REDIRECTS=0 log d "Not implemented: Client certificates" # TODO: recheck the speck die "$((100 + code))" "[$code] $meta" ;; *) [[ -z "${code-}" ]] && die 100 "Empty response code." die "$((100 + code))" "Unknown response code: $code." ;; esac } # GOPHER # https://tools.ietf.org/html/rfc1436 protocol # https://tools.ietf.org/html/rfc4266 url gopher_request() { local url server port type path url="$1" port=70 # RFC 4266 [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]] server="${BASH_REMATCH[1]}" port="${BASH_REMATCH[3]:-70}" type="${BASH_REMATCH[6]:-1}" path="${BASH_REMATCH[7]}" log d "URL='$url' SERVER='$server' TYPE='$type' PATH='$path'" exec 9<>"/dev/tcp/$server/$port" printf '%s\r\n' "$path" >&9 passthru <&9 } gopher_response() { local url pre type cur_server pre=false url="$1" # RFC 4266 [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]] cur_server="${BASH_REMATCH[1]}" type="${BASH_REMATCH[6]:-1}" run history_append "$url" "" # TODO: get the title ?? log d "TYPE='$type'" case "$type" in 0) # text run display text/plain ;; 1) # menu run gopher_convert | run display text/gemini ;; 3) # failure die 203 "GOPHER: failed" ;; 7) # search if [[ "$url" =~ $'\t' ]]; then run gopher_convert | run display text/gemini else run prompt 'SEARCH' run blastoff "$url $REPLY" fi ;; *) # something else run download "$url" ;; esac } passthru() { while IFS= read -r; do printf '%s\n' "$REPLY" done } gopher_convert() { local type label path server port regex # cf. https://github.com/jamestomasino/dotfiles-minimal/blob/master/bin/gophermap2gemini.awk while IFS= read -r; do printf -v regex '(.)([^\t]*)(\t([^\t]*)\t([^\t]*)\t([^\t]*))?' if [[ "$REPLY" =~ $regex ]]; then type="${BASH_REMATCH[1]}" label="${BASH_REMATCH[2]}" path="${BASH_REMATCH[4]:-/}" server="${BASH_REMATCH[5]:-$cur_server}" port="${BASH_REMATCH[6]}" else log e "CAN'T PARSE LINE" printf '%s\n' "$REPLY" continue fi case "$type" in .) # end of file printf '.\n' break ;; i) # label case "$label" in '#'* | '*'[[:space:]]*) if $pre; then printf '%s\n' '```' pre=false fi ;; *) if ! $pre; then printf '%s\n' '```' pre=true fi ;; esac printf '%s\n' "$label" ;; h) # html link if $pre; then printf '%s\n' '```' pre=false fi printf '=> %s %s\n' "${path:4}" "$label" ;; T) # telnet link if $pre; then printf '%s\n' '```' pre=false fi printf '=> telnet://%s:%s/%s%s %s\n' \ "$server" "$port" "$type" "$path" "$label" ;; *) # other type if $pre; then printf '%s\n' '```' pre=false fi printf '=> gopher://%s:%s/%s%s %s\n' \ "$server" "$port" "$type" "$path" "$label" ;; esac done if $pre; then printf '%s\n' '```' fi # close the connection exec 9<&- exec 9>&- } display() { # display METADATA [TITLE] local -a less_cmd local i mime charset # split header line local -a hdr IFS=';' read -ra hdr <<<"$1" # title is optional but nice looking local title if (($# == 2)); then title="$2" fi mime="$(trim "${hdr[0],,}")" for ((i = 1; i <= "${#hdr[@]}"; i++)); do h="${hdr[$i]}" case "$h" in *charset=*) charset="${h#*=}" ;; esac done [[ -z "$mime" ]] && mime="text/gemini" [[ -z "$charset" ]] && charset="utf-8" log debug "mime='$mime'; charset='$charset'" case "$mime" in text/*) set_title "$BOLLUX_URL" less_cmd=(less -R) mklesskey "$BOLLUX_LESSKEY" && less_cmd+=(-k "$BOLLUX_LESSKEY") less_cmd+=( -Pm"$title${title:+ - }bollux$" -PM'o\:open, g\:goto, [\:back, ]\:forward, r\:refresh$' -m ) local typeset local submime="${mime#*/}" if declare -Fp "typeset_$submime" >/dev/null; then typeset="typeset_$submime" else typeset="passthru" fi { run iconv -f "${charset^^}" -t "UTF-8" | run tee "$BOLLUX_PAGESRC" | run "$typeset" | run "${less_cmd[@]}" && bollux_quit } || run handle_keypress "$?" ;; *) 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 G quit 5 # 53 goto a url (pre-filled) # other keybinds \\40 forw-screen-force END } normalize() { 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 ;; '*'[[:space:]]*) gemini_list "$REPLY" $pre ;; *) 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 run blastoff -u "$REPLY" ;; 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) # G - goto a url (pre-filled with current) prompt -u GO run blastoff -u "$REPLY" ;; *) # 54-57 -- still available for binding die "$?" "less(1) error" ;; esac } select_url() { run mapfile -t < <(extract_links <"$1") PS3="OPEN> " select u in "${MAPFILE[@]}"; do case "$REPLY" in q) bollux_quit ;; [^0-9]*) run blastoff -u "$REPLY" && break ;; esac run blastoff "${u%%[[:space:]]*}" && break done [[:space:]]*([^[:space:]]+)([[:space:]]+(.*))?$ ]]; then url="${BASH_REMATCH[1]}" alt="${BASH_REMATCH[3]}" if [[ "$alt" ]]; then printf '%s \e[34m(%s)\e[0m\n' "$url" "$alt" else printf '%s\n' "$url" fi fi done } 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 } bollux_init() { # Trap cleanup trap bollux_cleanup INT QUIT EXIT # State REDIRECTS=0 set -f # History declare -a HISTORY # history is kept in an array HN=0 # position of history in the array run mkdir -p "${BOLLUX_HISTFILE%/*}" } bollux_cleanup() { # XXX : #kill $(jobs -p) } history_append() { # history_append url TITLE BOLLUX_URL="$1" # date/time, url, title (best guess) run printf '%(%FT%T)T\t%s\t%s\n' -1 "$1" "$2" >>"$BOLLUX_HISTFILE" HISTORY[$HN]="$BOLLUX_URL" ((HN += 1)) } history_back() { log d "HN=$HN" ((HN -= 2)) if ((HN < 0)); then HN=0 log e "Beginning of history." return 1 fi run blastoff "${HISTORY[$HN]}" } history_forward() { log d "HN=$HN" if ((HN >= ${#HISTORY[@]})); then HN="${#HISTORY[@]}" log e "End of history." return 1 fi run blastoff "${HISTORY[$HN]}" } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then run bollux "$@" fi