Initial upload

This commit is contained in:
Luxferre
2023-03-29 17:35:47 +03:00
commit 538c6520f4
3 changed files with 441 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>
+83
View File
@@ -0,0 +1,83 @@
# Bopher-NG: A better Gopher browser in pure Bash
> “Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away.”
> _― Antoine de Saint-Exupéry, Airman's Odyssey_
## What is it?
Bopher-NG is an ambitious attempt to write a full-featured Gopher client/browser in under 300 SLOC of pure Bash code. It started off as a really crude and unoptimized prototype developed right in [this blog post](https://chronovir.us/2023/03/28/I-wrote-a-browser/) for educational purposes.
Improvements over the original Bopher from that post:
- mouse support (where possible)
- smoother rendering and scrolling
- better edge-case stability
- 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
- ability to force-download pages instead of viewing regardless of their type
"Pure Bash" means any external commands must only be used if absolutely required. For now, Bopher-NG only depends on three external commands (all POSIX-compliant):
- `cat` (to handle possibly binary data read from the socket)
- `stty` (to fetch current terminal size in rows and columns)
- `date` (to shape entry timestamps when stashing links)
## Which Bash versions are supported?
Bopher-NG was only tested on Bash 5.1, but should support any version from 4.2 and up.
## How to start Bopher-NG?
Just run it specifying all the necessary components of the Gopher resource:
```
bopher-ng.sh host[ port][ selector][ type]
```
Or, you can just specify a valid `gopher://` URL:
```
bopher-ng.sh gopher://host[:port]/[type][selector]
```
E.g. running `bopher-ng.sh gopher://texto-plano.xyz:70/0comandos.txt` is the same as `bopher-ng.sh texto-plano.xyz 70 comandos.txt 0` and will open the same document.
In both cases, only the host is required. If everything else is omitted, the port will default to 70, the selector will default to `/` and the resource type will default to 1.
## How to control Bopher-NG?
- Scrolling: Up/Down arrow keys, k/j keys or mouse scroll wheel (if supported)
- Page scrolling: PgUp/PgDn keys or h/l keys
- Link navigation (keyboard): s - focus on the next link, w - focus on the previous link, Enter/Space - visit the focused link (or download if it points to a binary file), d - force-download the contents of the link
- Link navigation (mouse, if supported): left click on the link - focus and visit (or download if it points to a binary file), middle click - focus and force-download the contents
- Go back: b or right mouse click anywhere (if supported)
- Stash the link to the currently open resource: S (shift+s)
- Quit the browser: q
## What is the link stash?
Link stash is a viable and interoperable alternative to both bookmarks and clipboard that can be implemented with pure Bash. Essentially, it's an append-only text file at a fixed location (`~/.bopher-links.txt` by default) where the user can instruct Bopher-NG to save the link to the currently viewed resource. For your convenience, links are stashed with a UTC-based timestamp and in the `gopher://` format, so they can be copied from the file later and used in other browsers.
You can override the `BOPHER_LINKSTASH` environment variable to change the location and name of this file if you need to.
## Where do the downloads go?
By default, they go to your current working directory, i.e. the directory you are running this script from. To override this with a fixed path, set the `BOPHER_DLDIR` environment variable.
## Which platforms is the mouse supported on?
Bopher-NG's mouse input is supported on any terminals that support the 1000 **and** 1006 mouse reporting modes. This includes but is not limited to:
- Linux GUI terminal emulators (xterm, Konsole, urxvt, any VTE-based terminal emulator);
- Linux bare terminals via GPM + [LCXterm](https://gitlab.com/AutumnMeowMeow/lcxterm) (sorry, I don't know why no one implemented it in GPM itself in 23 years);
- macOS Terminal;
- OpenGL-based xterm-compatible terminal emulators like WezTerm, Kitty, Alacritty etc.
Note that this support is required at the client side only. For instance, you can safely run `bopher-ng.sh` on any compatible Bash version in an SSH session on your terminal and still be able to use mouse there.
## What is the license on this?
Fully public domain
Executable
+332
View File
@@ -0,0 +1,332 @@
#!/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
# - 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
# - ability to force-download pages instead of viewing regardless of their type
# 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"
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
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
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 '%b' "$MOUSEOFF$ERESET$ALTBUFOFF"
exit 0
}
trap clear_term INT
# fetch any Gopher resource
gophetch() { # args: host, port, selector[, input]
exec 3<>/dev/tcp/$1/$2 # bind the descriptor 3 to a /dev/tcp pseudo-device
if [[ -z "$4" ]]; then
printf '%s\r\n' "$3" >&3 # send the selector string (printf is more reliable)
else
printf '%s\t%s\r\n' "$3" "$4" >&3 # send the selector + tab + input string
fi
cat <&3 # fetch and output the result (we have to use cat as the result may be binary)
exec 3<&- # 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%%$'\r'}") # also remove a trailing CR in case the response is CRLF
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" ]]; 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:}"
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
}
# 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, 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]}"
if [[ 'D' == "$action" ]]; then # download
local fname="${sel##*/}" # pure-bash version of basename
[[ -z "$fname" ]] && fname='dl.dat' # just make sure that it's not empty
gophetch "$rhost" "$rport" "$sel" > "$BOPHER_DLDIR/$fname"
printf 'E\tDownloaded %s to %s\n' "$(amtogopher "$1")" "$BOPHER_DLDIR/$fname"
elif [[ 'I' == "$action" || 'M' == "$action" || 'P' == "$action" ]]; then # text content
local input=''
if [[ 'I' == "$action" ]]; then
printf '%b' "$LINECLR" # clear the current line on the terminal
read -p "Enter input for $rhost: " input
fi
readarray -t lines < <(gophetch "$rhost" "$rport" "$sel" "$input")
for line in "${lines[@]}"; do # iterate over every fetched line
if [[ 'P' == "$action" ]]; then # generate a plaintext AM entry
printf 'E\t%s\n' "${line%%$'\r'}" # remove a trailing CR if it's there
else # generate a proper Gophermap AM entry
gmparse "$line" "$rhost" "$rport"
fi
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<upperbound;idx++)); do # only iterate over eligible buffer lines by index
local amline="${SCREENBUF[$idx]}" # current AM line
readarray -d $'\t' -t fields < <(printf '%s' "$amline")
local action="${fields[0]}" # to display an AM entry, we only need two of its fields
local desc="${fields[1]}"
if [[ 'E' == "$action" ]]; then # plain text
output="${output}$desc\n"
else # not plain text
if [[ "$idx" == "$FOCUSLINEINDEX" ]]; then # if the current link is focused, then...
output="${output}$FOCUSATTR" # print the focus attribute (the link contains ERESET anyway)
fi
output="${output}$LINKCOLOR=> ${desc}$ERESET\n"
fi
done
# final preparations:
# - clear everything that remains and reset the cursor to the last line
# - output current resource to the status line
# - reset the cursor once again
output="${output}$BELOWCLR$ESC[$TERMROWS;0H$FOCUSATTR$(amtogopher "$CURRENTAM")$ERESET$ESC[$TERMROWS;0H"
printf '%b' "$output" # finally, print the entire output with a single call
}
trap 'amrender' WINCH # rerender on terminal size change
scroll() { # args: delta (usually 1 or -1)
local buflen=${#SCREENBUF[@]} # total length of the screen buffer
local newpos=$(( $CURSCROLLPOS + $1 )) # add the delta
local maxscroll=$(( $buflen - $TERMROWS + 1 ))
(( $newpos >= $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 '%b' "${LINECLR}Loading..."
[[ ! -z "$2" ]] && CURRENTAM="D${CURRENTAM:1}" # modify CURRENTAM first char to the download type
readarray -t SCREENBUF < <(amclick "$CURRENTAM") # 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
jumplink 0 # focus on the current link
}
clicklink() { # click the currently focused link
if (( $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")"
printf '[%s] %s\n' "$(date -u '+%F %T')" "$gopherlink" >> $BOPHER_LINKSTASH
printf 'Stashed %s%b' "$gopherlink" "$ERESET$ESC[$TERMROWS;0H" # just reset the cursor to the last line
}
# 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 '%b' "$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
printf '%b' "$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
printf '%b' "$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" == 's' ]] && jumplink 1
[[ "$cmdbuf" == 'w' ]] && jumplink -1
[[ "$cmdbuf" == 'q' ]] && printf '%b' "$ERESET$ALTBUFOFF" && exit 0
[[ "$cmdbuf" == $'\0a' ]] && clicklink
[[ "$cmdbuf" == 'd' ]] && clicklink 1 # same as click but with force-download flag
[[ "$cmdbuf" == 'b' ]] && goback
[[ "$cmdbuf" == 'S' ]] && stashlink
[[ "$mousecmd" == '0' && "$mouseStatus" == 'M' ]] && mouseclick $mouseX $mouseY # we only process left click with coordinates here
[[ "$mousecmd" == '1' && "$mouseStatus" == 'M' ]] && mouseclick $mouseX $mouseY 1 # we only process middle click with coordinates here
done