#!/bin/bash # BopherNG: a bit more advanced Gopher client in pure Bash # with a few external command dependencies (cat, stty, date) # Created by Luxferre in 2023, released into public domain # # Improvements over the original Bopher: # - mouse support # - smoother rendering and scrolling # - better edge-case stability # - better Gophermap processing according to the RFC1436 # - an actual text reflow when viewing plaintext documents with no hard wrapping # - multi-level navigation history (although you can only go back) # - status bar with currently opened resource name # - ability to accept gopher:// URLs from the command line # - ability to save the URL to the currently viewed resource into the stash text file # - optional clipboard support when stashing (only if you set BOPHER_CLIP envvar) # - ability to force-download pages instead of viewing regardless of their type # - displaying type 8 entries (as per RFC1436) as the telnet:// URI scheme shopt -s extglob # enable extended pattern matching (just to be sure) # save the command line parameters START_HOST="$1" START_PORT="$2" START_SEL="$3" START_TYPE="$4" if [[ "$START_HOST" =~ ^gopher:// ]]; then # parse the URL preurl="${START_HOST#gopher://}" # remove the scheme to ease parsing hostport="${preurl%%/*}" # extract the host+:port part (where :port is also optional) selpath="${preurl##$hostport}" # extract the selector+path part START_HOST="${hostport%%:*}" # extract the hostname START_PORT="${hostport:(( 1 + ${#START_HOST} ))}" # extract the port START_TYPE="${selpath:1:2}" # extract the type character START_SEL="${selpath:2}" # extract the selector fi [[ -z "$START_HOST" ]] && echo "Usage: bopher-ng.sh host[ port][ selector][ type] OR bopher-ng.sh gopher://host[:port]/[type][selector]" && exit 1 [[ -z "$START_PORT" ]] && START_PORT=70 [[ -z "$START_SEL" ]] && START_SEL="/" [[ -z "$START_TYPE" ]] && START_TYPE=1 # default request type is a Gophermap # define external paths [[ -z "$BOPHER_LINKSTASH" ]] && BOPHER_LINKSTASH="$HOME/.bopher-links.txt" # file to stash links to [[ -z "$BOPHER_DLDIR" ]] && BOPHER_DLDIR="$PWD" # downloads directory # styling used in the TUI ESC=$'\x1b' # use the ANSI literal syntax to support in read prompts as well LF=$'\x0a' # let's do this ERESET="${ESC}[0m" # reset the styling ERRCOLOR="${ESC}[31;1m" # red bold LINKCOLOR="${ESC}[36;1m" # cyan bold FOCUSATTR="${ESC}[7;2m" # swap foreground and background and brightness level CLS="${ESC}[2J" # clear the entire screen CURRESET="${ESC}[0;0H" # reset the visual cursor CURSTART="${ESC}[1G" # move cursor to the start of the line LINECLR="${ESC}[2K" # clear current line BELOWCLR="${ESC}[0J" # clear everything below ALTBUFON="${ESC}[?47h" # use the alternative screen buffer ALTBUFOFF="${ESC}[?47l" # return to the default screen buffer # mouse event support switches MOUSE_ENABLED=1 MOUSEON="${ESC}[?1000;1006h" # term command to start sending mouse input events as control sequences (semi-long format, no drag) MOUSEOFF="${ESC}[?1000;1006l" # term command to stop sending mouse input events as control sequences (semi-long format, no drag) # in the semi-long format without drag, every mouse control sequence starts with "${ESC}[<" and then has three fields: # button_attr;xcoord;ycoord[Mm] clear_term() { # clear the terminal on Ctrl+C interrupt printf '%s' "$MOUSEOFF$ERESET$ALTBUFOFF" exit 0 } trap clear_term INT # fetch any Gopher resource gophetch() { # args: host, port, selector[, input] exec 4<>/dev/tcp/$1/$2 # bind the descriptor 4 to a /dev/tcp pseudo-device if [[ -z "$4" ]]; then printf '%s\r\n' "$3" >&4 # send the selector string (printf is more reliable) else printf '%s\t%s\r\n' "$3" "$4" >&4 # send the selector + tab + input string fi cat <&4 # fetch and output the result (we have to use cat as the result may be binary) exec 4<&- # close the descriptor to make it reusable } # parse a single Gophermap line according to the current host and port # Return format: ACTION[tab]DESCRIPTION[tab]HOST[tab]PORT[tab]SELECTOR[tab]TYPE # where ACTION can be: # - E (echo the description - this line is non-navigable) # - P (print the file) # - D (download the file) # - M (render a Gophermap) # - I (ask for the user input and render a Gophermap) gmparse() { # args: line, curhost, curport local line="$1" [[ ! "$line" == *[$'\t']* ]] && line="i$line" # treat non-standard plain tabless lines as information lines readarray -d $'\t' -t fields < <(printf '%s' "$line") local rtype="${fields[0]:0:1}" # resource type local desc="${fields[0]:1}" # resource description local sel="${fields[1]}" # resource selector local rhost="${fields[2]}" # resource hostname local rport="${fields[3]}" # resource port if [[ -z "$sel$rhost$rport" ]]; then # if all fields except description are empty sel="$desc" # then assume our selector is in the description field fi [[ -z "$rhost" ]] && rhost="$2" # fill in the missing hostname [[ -z "$rport" ]] && rport="$3" # fill in the missing port number local action='D' # all unknown types are set to download if [[ 'i' == "$rtype" || '3' == "$rtype" || 'h' == "$rtype" || '8' == "$rtype" ]]; then # handle information lines action='E' [[ '3' == "$rtype" ]] && desc="$ERRCOLOR$desc$ERESET" # wrap error messages in the coloration terminal commands # handle external URLs as information lines of a special kind [[ 'h' == "$rtype" ]] && desc="$desc: ${sel#URL:}" if [[ '8' == "$rtype" ]]; then # handle 8-type resources as telnet:// URIs local username="${sel}@" [[ -z "${sel}" ]] && username='' desc="$desc: telnet://${username}${rhost}:${rport}" fi fi [[ '0' == "$rtype" ]] && action='P' # plain text is plain text [[ '1' == "$rtype" ]] && action='M' # it's a Gophermap [[ '7' == "$rtype" ]] && action='I' # it's a Gophermap with user input (search or whatever) printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$action" "$desc" "$rhost" "$rport" "$sel" "$rtype" # output the final formatted line } phlow_lite() { # a single-parameter line reflow algorithm local line="$1" local TARGET_WIDTH="$2" local reflowfmt="%-${TARGET_WIDTH}s\n" local llen="${#line}" # get effective line length if (( 0 == TARGET_WIDTH || llen < TARGET_WIDTH )); then # no need to run the logic for smaller lines or if TARGET_WIDTH is 0 printf "$reflowfmt" "$line" return fi local lastws=0 # variable to track last whitespace local cpos=0 # variable to track current position within the page line local pagepos=0 # variable to track the position of new line start local outbuf='' # temporary output buffer local c='' # temporary character buffer for ((i=0;i= TARGET_WIDTH )); then # we already exceeded the page width (( lastws == 0 )) && lastws=$TARGET_WIDTH # no whitespace encountered here printf "$reflowfmt" "${outbuf:0:$lastws}" # truncate the buffer outbuf='' pagepos=$(( pagepos + lastws )) cpos=0 lastws=0 i=$pagepos # update current iteration index from the last valid whitespace else # save the whitespace position if found c="${line:i:1}" # get the current character [[ "$c" == $'\x20' ]] && lastws="$cpos" outbuf="${outbuf}${c}" # save the character itself fi done [[ ! -z "$outbuf" ]] && printf "$reflowfmt" "$outbuf" # output the last unprocessed chunk } # convert AM line back to gopher:// URL amtogopher() { # args: AM line readarray -d $'\t' -t fields < <(printf '%s' "$1") printf 'gopher://%s:%s/%s%s' "${fields[2]}" "${fields[3]}" "${fields[5]}" "${fields[4]}" } # AM entry clicker function amclick() { # args: AM line[, forcedl], output: AM line(s) readarray -d $'\t' -t fields < <(printf '%s' "$1") local action="${fields[0]}" local desc="${fields[1]}" local rhost="${fields[2]}" local rport="${fields[3]}" local sel="${fields[4]}" local input='' local puresel="${sel%%\%09*}" # extract the pure selector (w/o possible input) if [[ "$sel" != "$puresel" ]]; then # check selector for %09, if so, extract the input string input="${sel##$puresel\%09}" fi if [[ 'I' == "$action" && -z "$input" ]]; then read -p "${LINECLR}${CURSTART}Enter input for $rhost: " input fi [[ ! -z "$2" ]] && action='D' # force the download action if [[ 'D' == "$action" ]]; then # download local fname="${puresel##*/}" # pure-bash version of basename [[ -z "$fname" ]] && fname='dl.dat' # just make sure that it's not empty [[ ! -z "$input" ]] && fname="${fname}%09${input}.search.txt" gophetch "$rhost" "$rport" "$puresel" "$input" > "$BOPHER_DLDIR/$fname" printf 'E\tDownloaded %s to %s\n' "$(amtogopher "$1")" "$BOPHER_DLDIR/$fname" elif [[ 'I' == "$action" || 'M' == "$action" ]]; then # Gophermaps must be processed with CRLF delimiter only readarray -t lines -d $'\r' < <(gophetch "$rhost" "$rport" "$puresel" "$input") # split on CR, not LF! for line in "${lines[@]}"; do # iterate over every fetched line line="${line##$'\n'}" # remove a starting LF if it's there line="${line%%$'\r'}" # remove a trailing CR if it's there [[ "$line" == '.' ]] && break # stop processing Gophermaps on . gmparse "$line" "$rhost" "$rport" done elif [[ 'P' == "$action" ]]; then # plain text content (can be delimited with both CRLF or LF) read -r TERMROWS TERMCOLS < <(stty size) # get current terminal size info readarray -t lines -d $'\n' < <(gophetch "$rhost" "$rport" "$puresel" "$input") # split on LF for line in "${lines[@]}"; do # iterate over every fetched line readarray -t reflow_lines -d $'\n' < <(phlow_lite "${line%%$'\r'}" "$TERMCOLS") # remove a trailing CR if it's there for rline in "${reflow_lines[@]}"; do printf 'E\t%s\n' "$rline" done done fi } declare -a SCREENBUF # screen buffer array placeholder declare -a NAVIGABLES # navigable indexes array placeholder declare -a AMHISTORY # history array containing the AM lines CURRENTAM='' # the AM line corresponding to the currently loaded resource CURLINKINDEX=0 # this is an index in the NAVIGABLES array, not SCREENBUF FOCUSLINEINDEX=-1 # this is an index in the SCREENBUF array CURSCROLLPOS=0 # current scrolling position read -r TERMROWS TERMCOLS < <(stty size) # get current terminal size info # main content rendering method (offscreen rendering) amrender() { read -r TERMROWS TERMCOLS < <(stty size) # update current terminal size info local upperbound=$(( $CURSCROLLPOS + $TERMROWS - 1 )) # leaving out the last line for status local buflen=${#SCREENBUF[@]} # total length of the screen buffer (( $upperbound > $buflen )) && upperbound=$buflen local output="$CLS$CURRESET" # start output by clearing the screen and resetting the cursor for ((idx=CURSCROLLPOS;idx= $maxscroll )) && newpos=$maxscroll (( $newpos < 0 )) && newpos=0 if (( $newpos != $CURSCROLLPOS )); then CURSCROLLPOS=$newpos (( $CURSCROLLPOS < 0 )) && CURSCROLLPOS=0 amrender # rerender the contents only if the position really changed fi } jumplink() { # args: delta (usually 1 or -1) local navlen=${#NAVIGABLES[@]} # total length of the nav array if (( $navlen > 1 )); then # do something only if there is at least two navigables local newindex=$(( $CURLINKINDEX + $1 )) # add the delta (( $newindex < 0 )) && newindex=$(( $navlen - 1 )) # wrap around at the beginning (( $newindex >= $navlen )) && newindex=0 # wrap around at the end CURLINKINDEX=$newindex # update the global navigable index FOCUSLINEINDEX=${NAVIGABLES[$CURLINKINDEX]} CURSCROLLPOS=$(( $FOCUSLINEINDEX - $(( $TERMROWS / 2 )) )) # attempt to center scrolling position at the currently focused link (( $CURSCROLLPOS < 0 )) && CURSCROLLPOS=0 scroll 0 # auto-adjust scrolling if the link is at the end amrender # trigger the rendering on update fi } amload() { # args: AM line[, forcedl] CURRENTAM="$1" # update current AM value printf '%s' "${LINECLR}Loading..." readarray -t SCREENBUF < <(amclick "$CURRENTAM" "$2") # populate screen buffer by loading the resource from the AM entry NAVIGABLES=() # reset the navigable indexes CURLINKINDEX=0 # reset the link number CURSCROLLPOS=0 # reset the scrolling position for idx in "${!SCREENBUF[@]}"; do # iterate over every fetched line by index local amline="${SCREENBUF[$idx]}" # current AM line local action="${amline:0:1}" # action character if [[ ! 'E' == "$action" ]]; then # for all link-related entries... NAVIGABLES+=("$idx") # append this index as navigable fi done FOCUSLINEINDEX=${NAVIGABLES[0]} # set the focused line index to the first available one amrender # render the contents } clicklink() { # click the currently focused link if [[ ! -z "$FOCUSLINEINDEX" ]] && (( $FOCUSLINEINDEX > -1 )); then AMHISTORY+=("$CURRENTAM") # update the history chain amload "${SCREENBUF[$FOCUSLINEINDEX]}" "$1" fi } goback() { # remove the last AM line from history and go there local histlen=${#AMHISTORY[@]} if (( $histlen > 1 )); then # at least one entry must be present local lastidx=$(( $histlen - 1 )) local histline="${AMHISTORY[$lastidx]}" AMHISTORY=("${AMHISTORY[@]:0:$lastidx}") amload "$histline" fi } stashlink() { # stash the link to the currently viewed resource local gopherlink="$(amtogopher "$CURRENTAM")" readarray -d $'\t' -t fields < <(printf '%s' "$CURRENTAM") # extract individual fields local desc="${fields[1]}" [[ -z "$desc" ]] && desc='Unnamed' printf '%s [%s] %s - %s\t%s\t%s\t%s\r\n' "${fields[5]}" "$(date -u '+%F %T')" "$desc" "$gopherlink" "${fields[4]}" "${fields[2]}" "${fields[3]}" >> $BOPHER_LINKSTASH printf 'Stashed %s - %s%s' "$desc" "$gopherlink" "$ERESET${ESC}[$TERMROWS;0H" # just reset the cursor to the last line [[ ! -z "$BOPHER_CLIP" ]] && (printf '%s' "$gopherlink" | $BOPHER_CLIP) # run an external clipboard command if it's explicitly specified } # Focus and click a link on a Gophermap mouseclick() { # args: x, y[, forcedl] (start with index 1) local curcol=$1 local currow=$2 local lineindex="$(( $currow + $CURSCROLLPOS - 1 ))" # determine the AM line index we're pointing to in the screen buffer local targetline="${SCREENBUF[$lineindex]}" # load the line we're testing local action="${targetline:0:1}" # get its action if [[ ! 'E' == "$action" ]]; then # only proceed if it's a link-related AM line readarray -d $'\t' -t fields < <(printf '%s' "$targetline") local desc="${fields[1]}" local dlen=$(( ${#desc} + 3 )) # consider the => link prefix if (( $curcol <= $dlen )); then # only load if the click was within the line FOCUSLINEINDEX=$lineindex amrender AMHISTORY+=("$CURRENTAM") # update the history chain amload "$targetline" "$3" fi fi } readmouseinput() { # correctly read the remaining mouse input in semi-long mode read -r -s -d ';' battr # the button attribute is before the first semicolon read -r -s -d ';' mx # the X coordinate is between the semicolons local my='' # allocate a buffer to fetch Y coordinate local c='' # cache character while [[ "$c" != 'm' && "$c" != 'M' ]]; do my="$my$c" read -s -n 1 c done # now, my contains our Y coordinate and c contains the press/release specifier printf '%d %d %d %c\n' "$battr" "$mx" "$my" "$c" } # entry point code starts here printf '%s' "$ALTBUFON$CLS" # enter ALTBUF mode and clear the screen # now, load the resource provided from the command line and start the main loop START_GMLINE="$(printf '%s\t%s\t%s\t%s' "$START_TYPE" "$START_SEL" "$START_HOST" "$START_PORT")" START_AM="$(gmparse "$START_GMLINE")" AMHISTORY+=("$START_AM") # start the history chain amload "$START_AM" cmdbuf='' # allocate key command buffer and mouse coordinate variables mouseX='' mouseY='' mouseStatus='M' while true; do mousecmd='' # mouse command buffer [[ "$MOUSE_ENABLED" == '1' ]] && printf '%s' "$MOUSEON" # enable mouse events before reading read -n 1 -s cmdbuf # read the character, tildas will be ignored if [[ "$cmdbuf" == $'\x1b' ]]; then # here's the control character read -n 2 -s cmdbuf # read 2 additional characters if [[ "$cmdbuf" == '[<' ]]; then # we have a mouse event, read the button attribute and the coordinates read -r -s mousecmd mouseX mouseY mouseStatus < <(readmouseinput) fi fi [[ "$MOUSE_ENABLED" == '1' ]] && printf '%s' "$MOUSEOFF" # don't generate mouse events outside the input sequence [[ "$mousecmd" == '2' && "$mouseStatus" == 'M' ]] && cmdbuf='b' # right click is automatically assigned to back command [[ "$cmdbuf" == '[A' || "$cmdbuf" == 'k' || "$mousecmd" == '64' ]] && scroll -1 [[ "$cmdbuf" == '[B' || "$cmdbuf" == 'j' || "$mousecmd" == '65' ]] && scroll 1 [[ "$cmdbuf" == '[5' || "$cmdbuf" == 'h' ]] && scroll $(( 1 - $TERMROWS )) # PgUp [[ "$cmdbuf" == '[6' || "$cmdbuf" == 'l' ]] && scroll $(( $TERMROWS - 1 )) # PgDn [[ "$cmdbuf" == 'r' ]] && amload "$CURRENTAM" # refresh the page without updating history [[ "$cmdbuf" == 's' ]] && jumplink 1 [[ "$cmdbuf" == 'w' ]] && jumplink -1 [[ "$cmdbuf" == 'q' ]] && printf '%s' "$ERESET$ALTBUFOFF" && exit 0 [[ "$cmdbuf" == '' ]] && clicklink # space or enter or tab [[ "$cmdbuf" == 'd' ]] && clicklink 1 # same as click but with force-download flag [[ "$cmdbuf" == 'b' ]] && goback [[ "$cmdbuf" == 'S' ]] && stashlink [[ "$cmdbuf" == 'm' ]] && MOUSE_ENABLED=$(( 1 - MOUSE_ENABLED )) [[ "$MOUSE_ENABLED" == '1' && "$mousecmd" == '0' && "$mouseStatus" == 'M' ]] && mouseclick $mouseX $mouseY # we only process left click with coordinates here [[ "$MOUSE_ENABLED" == '1' && "$mousecmd" == '1' && "$mouseStatus" == 'M' ]] && mouseclick $mouseX $mouseY 1 # we only process middle click with coordinates here done