astro

a POSIX shell compatible gemini client (mirror of https://github.com/blmayer/astro)
git clone https://git.clttr.info/astro.git
Log (Feed) | Files | Refs (Tags) | README | LICENSE

astro (14355B)


      1 #!/bin/sh
      2 
      3 version="0.25.1"
      4 
      5 usage() {
      6 	echo "astro v$version: Browse the gemini web on the terminal."
      7 	echo ""
      8 	echo "Usage: astro [URL]|[OPTION]"
      9 	echo ""
     10 	echo "Options:"
     11 	echo "  -h, --help		show this help"
     12 	echo "  -v, --version		show version info"
     13 	echo ""
     14 	echo "Configuration:"
     15 	echo "You can setup a config file at ~/.config/astro/astro.conf to configure *astro* the way you like."
     16 	echo ""
     17 	echo "Commands:"
     18 	echo "These are the default keybindings to use while running astro:"
     19 	echo ""
     20 	echo "  q	quit"
     21 	echo "  g	go to a link"
     22 	echo "  r	reload current page"
     23 	echo "  b	go back one page"
     24 	echo "  u	jump one path segment up"
     25 	echo "  o	open an address"
     26 	echo "  s	save current page"
     27 	echo "  H	go to homepage"
     28 	echo "  m	add bookmark"
     29 	echo "  M	go to a bookmark"
     30 	echo "  K	remove bookmark for current url"
     31 	echo ""
     32 	echo "Examples:"
     33 	echo "  astro		Start browsing the default webpage"
     34 	echo "  astro url	Start browsing url"
     35 	echo "  astro --help	Show help"
     36 	echo ""
     37 	echo "Report bugs to: bleemayer@gmail.com"
     38 	echo "Home page: <https://www.github.com/blmayer/astro/>"
     39 	echo "General help: <https://www.github.com/blmayer/astro/wiki>"
     40 }
     41 
     42 version() {
     43 	echo "astro $version"
     44 	echo ""
     45 	echo "Copyright (C) 2021-2023 Brian Mayer."
     46 	echo "License MIT: MIT License <https://opensource.org/licenses/MIT>"
     47 	echo "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,"
     48 	echo "EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF"
     49 	echo "MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT."
     50 	echo ""
     51 	echo "Written by Brian Lee Mayer."
     52 }
     53 
     54 debug() {
     55 	[ "$debug" ] && echo "DEBUG: $*" >&2 && sleep "$debug"
     56 }
     57 
     58 # Parse arguments
     59 debug "parsing args"
     60 while [ "$1" ]
     61 do
     62 	case $1 in
     63 		"-v")
     64 			debug=1
     65 			;;
     66 		"-h" | "--help")
     67 			usage
     68 			exit 0
     69 			;;
     70 		"--version")
     71 			version
     72 			exit 0
     73 			;;
     74 		"-f"|"--file")
     75 			shift
     76 			file="$1"
     77 			;;
     78 		"")
     79 			break
     80 			;;
     81 		*)
     82 			url="$1"
     83 			;;
     84 	esac
     85 	shift
     86 done
     87 
     88 # Save terminal
     89 tput smcup
     90 
     91 # Configuration
     92 confighome=${XDG_CONFIG_HOME:-$HOME/.config}
     93 mkdir -p "$confighome/astro"
     94 configfile="$confighome/astro/astro.conf"
     95 bookmarkfile="$confighome/astro/bookmarks"
     96 certdir="$confighome/astro/certs"
     97 mkdir -p "$certdir"
     98 
     99 cachedir="${XDG_CACHE_HOME:-$HOME/.cache}/astro"
    100 
    101 # Default values
    102 
    103 # user config
    104 margin=8
    105 homepage="geminiprotocol.net/"
    106 
    107 # keybindings
    108 openkey='o'
    109 openlocalkey='O'
    110 gokey='g'
    111 refreshkey='r'
    112 backkey='b'
    113 quitkey='q'
    114 markkey='b'
    115 gomarkkey='M'
    116 delmarkkey='K'
    117 goupkey='u'
    118 homekey='H'
    119 
    120 # styles
    121 sty_header1='\033[35;7;1m'
    122 sty_header2='\033[35;4;1m'
    123 sty_header3='\033[35;4m'
    124 sty_quote='\033[2;3m  '
    125 sty_linkb='\033[35m'
    126 sty_linkt=' => \033[36;3m '
    127 sty_listb='\033[35;1m  •'
    128 sty_listt='\033[0m '
    129 
    130 if [ ! -s "$configfile" ]
    131 then
    132 	# Default values
    133 	cat <<- EOF > "$configfile"
    134 	# where to store temp files
    135 	cachedir="$cachedir"
    136 
    137 	# user config
    138 	margin=8
    139 	homepage="gemini.circumlunar.space/"
    140 
    141 	# keybindings
    142 	openkey='o'
    143 	openlocalkey='O'
    144 	gokey='g'
    145 	refreshkey='r'
    146 	backkey='b'
    147 	quitkey='q'
    148 	markkey='b'
    149 	gomarkkey='M'
    150 	delmarkkey='K'
    151 	goupkey='u'
    152 	homekey='H'
    153 
    154 	# styles
    155 	sty_header1='\033[35;7;1m'
    156 	sty_header2='\033[35;4;1m'
    157 	sty_header3='\033[35;4m'
    158 	sty_quote='\033[2;3m  '
    159 	sty_linkb='\033[35m'
    160 	sty_linkt=' => \033[36;3m '
    161 	sty_listb='\033[35;1m  •'
    162 	sty_listt='\033[0m '
    163 	EOF
    164 fi
    165 
    166 # shellcheck source=/dev/null
    167 . "$configfile"
    168 
    169 mkdir -p "$cachedir"
    170 pagefile="$(mktemp -p "$cachedir" -t curpage.XXXXXX)"
    171 histfile="$(mktemp -p "$cachedir" -t history.XXXXXX)"
    172 linksfile="$(mktemp -p "$cachedir" -t links.XXXXXX)"
    173 tracefile="$(mktemp -p "$cachedir" -t trace.XXXXXX)"
    174 debug "read configs"
    175 
    176 # Restore terminal
    177 trap 'rm -f $histfile $linksfile $pagefile $tracefile > /dev/null 2>&1 && tput rmcup && printf "\033[?25h" && stty echo && exit' EXIT INT HUP
    178 
    179 stop() {
    180 	[ "$trace" ] || return
    181 	if [ -z "$stopwatch" ]
    182 	then
    183 		stopwatch=$(date +%s.%N)
    184 	else
    185 		dur=$(echo "$(date +%s.%N) - $stopwatch" | bc)
    186 		printf "%s took %s seconds\n" "$1" "$dur" >> "$tracefile"
    187 		unset stopwatch
    188 	fi
    189 }
    190 
    191 getprevious() {
    192 	sed -i '$d' "$histfile"
    193 	prev="$(tail -n 1 "$histfile")"
    194 	sed -i '$d' "$histfile"
    195 	echo "$prev"
    196 }
    197 
    198 # Returns the complete url scheme with gemini defaults
    199 # Parameters: url
    200 parseurl() {
    201 	# Credits: https://stackoverflow.com/a/6174447/7618649
    202 	debug "parsing: $url oldhost: $oldhost oldpath: $oldpath"
    203 	proto="$(echo "$url" | grep :// | sed -e 's,^\(.*://\).*,\1,g')"
    204 	if [ "$proto" ]
    205 	then 
    206 		url="$(echo "$url" | sed -e "s@$proto@@g")"
    207 	else
    208 		if [ "$oldhost" ]
    209 		then
    210 			case "$url" in
    211 				"/"*) url="$oldhost$url" ;;
    212 				*) oldpath="/${oldpath#/*}"; url="$oldhost${oldpath%/*}/$url" ;;
    213 			esac
    214 		fi
    215 	fi
    216 	debug "url: $url"
    217 
    218 	proto="$(echo "$proto" | sed -e 's,:\?//,,g')"
    219 	user="$(echo "$url" | grep @ | cut -d@ -f1)"
    220 	hostport="$(echo "$url" | sed -e "s/$user@//g" | cut -d/ -f1)"
    221 	host="$(echo "$hostport" | sed -e 's,:.*,,g')"
    222 	port="$(echo "$hostport" | sed -e 's,^.*:,:,g' -e 's,.*:\([0-9]*\).*,\1,g' -e 's,[^0-9],,g')"
    223 	path="$(echo "${url#/*}" | sed "s@/\?$hostport@@")"
    224 
    225 	debug "parsed: proto: ${proto:-gemini} host: $host port: ${port:-1965} path: ${path#/*}"
    226 	echo "${proto:-gemini}" "$host" "${port:-1965}" "${path#/*}" "$rest"
    227 }
    228 
    229 typesetgmi() {
    230 	# some setup first
    231 	[ -f "$linksfile" ] && rm "$linksfile"
    232 	cols=$(tput cols)
    233 	linkcount="0"
    234 
    235 	# shellcheck disable=SC2154
    236 	width=$((cols - (2*margin)))
    237 	debug "text width: $width"
    238 
    239 	stop
    240 	while IFS='' read -r line || [ -n "$line" ];
    241 	do
    242 		# shellcheck disable=SC2059
    243 		line="$(printf "$line\n" | tr -d '\r')"
    244 		printf "$line\n" | grep -q "^\`\`\`" && pre=$((1 - pre)) && line=""
    245 
    246 		# add margins and fold
    247 		if [ "$pre" = 1 ]
    248 		then
    249 			printf '%*s%s\n' "$margin" "" "$line"
    250 			continue
    251 		fi
    252 
    253 		# shellcheck disable=SC2154
    254 		case "$line" in
    255 			"### "*) sty="$sty_header3" && line="${line#'### '}" ;;
    256 			"## "*) sty="$sty_header2" && line="${line#'## '}"  ;;
    257 			"# "*) sty="$sty_header1" && line="${line#'# '}" ;;
    258 			"> "*) sty="$sty_quote" && line="${line#> }" ;;
    259 			"=>"*)
    260 				link="$(echo "${line#'=>'}" | tr -s '\t' ' ')"
    261 				echo "${link#' '}" >> "$linksfile"
    262 				linkcount=$((linkcount+1))
    263 
    264 				line="$(echo "$link" | cut -d' ' -f2-)"
    265 				[ -z "$line" ] && line="$link"
    266 
    267 				sty="$sty_linkb${linkcount}$sty_linkt"
    268 				;;
    269 			'* '*) sty="$sty_listb$sty_listt" && line="${line#* }";;
    270 			*) sty='' ;;
    271 		esac
    272 
    273 		# shellcheck disable=SC2059
    274 		printf -- "$line\n" | fold -w "$width" -s | while IFS='' read -r txt
    275 		do
    276 			printf "%*s" "$margin" ""
    277 
    278 			# shellcheck disable=SC2059
    279 			printf -- "$sty$txt\n\033[m"
    280 		done
    281 	done
    282 	stop "typeset"
    283 }
    284 
    285 # some help:
    286 # \033[;H	move to top 
    287 # \033[NH 	move to bottom N lines
    288 # \033[?25l	hide cursor
    289 # \033[?25h	unhide cursor
    290 # \033[2K	erase line
    291 pager() {
    292 	clear
    293 
    294 	# hide cursor
    295 	printf '\033[?25l'
    296 
    297 	# lines columns
    298 	lines="$(tput lines)"
    299 	head -n "$((lines-1))" "$1"
    300 	l="$(wc -l < "$1")"
    301 	if [ "$lines" -lt "$l" ]; then pos="$lines"; else pos="$l"; fi
    302 
    303 	# stop echoing user input and read input unbufered
    304 	stty -echo -icanon min 1 time 0
    305 
    306 	# read inputs
    307 	while k="$(dd bs=1 count=1 status=none | od -c -An | tr -d ' ')"
    308 	do
    309 		case "$k" in
    310 		# command sequences
    311 		"033")
    312 			b="$(dd bs=2 count=1 status=none)"
    313 			case "$b" in
    314 				# up arrow
    315 				'[A')
    316 					[ "$pos" -le "$lines" ] && continue
    317 					line="$(sed "$((pos-lines))q;d" "$1")"
    318 					pos="$((pos-1))"
    319 					printf '\033[H\033[L%s\033[%sH\033[2K' "$line" "$lines"
    320 					;;
    321 				# down arrow
    322 				'[B')
    323 					[ "$pos" -ge "$l" ] && continue
    324 					printf '\033[%sH' "$lines"
    325 					sed "${pos}q;d" "$1"
    326 					pos="$((pos+1))"
    327 					;;
    328 				# page up
    329 				'[5')
    330 					# discard one extra byte
    331 					dd bs=1 count=1 status=none > /dev/null
    332 
    333 					[ "$pos" -le "$lines" ] && continue
    334 					scroll="$((pos-lines))"
    335 					[ "$scroll" -gt "$lines" ] && scroll="$((lines-1))"
    336 
    337 					# shellcheck disable=SC2086
    338 					for i in $(seq 1 "$scroll")
    339 					do
    340 						line="$(sed "$((pos-lines))q;d" "$1")"
    341 						pos="$((pos-1))"
    342 						printf '\033[H\033[L%s\033[%sH\033[2K' "$line" "$lines"
    343 					done
    344 					;;
    345 				# page down
    346 				'[6') 
    347 					# discard one extra byte
    348 					dd bs=1 count=1 status=none > /dev/null
    349 
    350 					[ "$pos" -ge "$l" ] && continue
    351 					scroll="$((lines-1))"
    352 					end="$((pos+scroll))"
    353 					[ "$end" -ge "$l" ] && scroll="$((l-pos))"
    354 
    355 					# shellcheck disable=SC2086
    356 					for i in $(seq 1 "$scroll")
    357 					do
    358 						printf '\033[%sH' "$lines"
    359 						sed "${pos}q;d" "$1"
    360 						pos="$((pos+1))"
    361 					done
    362 					;;
    363 			esac ;;
    364 		"$quitkey") exit 0 ;;
    365 		"$openkey")
    366 			printf '\033[%sH\033[?25h\033[2KType url: ' "$lines"
    367 			stty echo icanon
    368 			read -r url <&1
    369 
    370 			# add gemini:// to begining if not (for convenience)
    371 			case "$url" in
    372 			       "gemini://"*) ;;
    373 			       *) url="gemini://$url" ;;
    374 			esac
    375 			return
    376 			;;
    377 		"$openlocalkey")
    378 			# Open local gmi file
    379 			printf '\033[?25h\033[2KType file path: '
    380 			stty echo
    381 			read -r file <&1
    382 			typesetgmi < "$file" > "$pagefile"
    383 			pager "$pagefile"
    384 			return
    385 			;;
    386 		"$refreshkey") return ;;
    387 		"$gokey")
    388 			printf '\033[?25h\033[2KEnter link number: '
    389 			stty echo icanon
    390 			read -r i <&1
    391 			debug "selected $i"
    392 			url="$(sed "${i}q;d" "$linksfile" | cut -f1 | cut -d' ' -f1)"
    393 			return
    394 			;;
    395 		"$backkey") 
    396 			read -r proto host port path <<- EOF
    397 			$(getprevious)
    398 			EOF
    399 			url="$proto://$host:$port/$path"
    400 			return
    401 			;;
    402 		"$homekey") url="$homepage"; return ;;
    403 		"$markkey") 
    404 			printf '\033[?25h\033[KEnter description: (optional)'
    405 			stty echo icanon
    406 			read -r desc <&1
    407 			echo "$url $desc" >> "$bookmarkfile"
    408 			return
    409 			;;
    410 		"$gomarkkey")
    411 			clear
    412 			cat -n "$bookmarkfile"
    413 			printf "\033[?25h\033[KEnter link number: "
    414 			stty echo icanon
    415 			read -r i <&1
    416 			url="$(sed "${i}q;d" "$bookmarkfile" | cut -d' ' -f1)"
    417 			return
    418 			;;
    419 		"$delmarkkey")
    420 			grep -iv "^$url " "$bookmarkfile" > "$cachedir/bookmarks"
    421 			mv "$cachedir/bookmarks" "$bookmarkfile"
    422 			return
    423 			;;
    424 		"$goupkey")
    425 			newpath=$(echo "$url" | rev | cut -d'/' -f2- | rev)
    426 			url="${url%/*}/$newpath"
    427 			return
    428 			;;
    429 		esac
    430 	done
    431 }
    432 
    433 # borrowed from https://gist.github.com/cdown/1163649
    434 urlencode() {
    435 	stop
    436 	old_lang=$LANG
    437 	LANG=C
    438 
    439 	old_lc_collate=$LC_COLLATE
    440 	LC_COLLATE=C
    441 
    442 	length="${#1}"
    443 	i=1
    444 	while [ "$i" -le "$length" ]
    445 	do
    446 		c=$(printf '%s' "$1" | cut -c $i)
    447 		case $c in
    448 			[a-zA-Z0-9.~_-]) printf '%s' "$c" ;;
    449 			*) printf '%%%02X' "'$c" ;;
    450 		esac
    451 		i=$((i+1))
    452 	done
    453 
    454 	LC_COLLATE=$old_lc_collate
    455 	LANG=$old_lang
    456 	stop "urlencode"
    457 }
    458 
    459 # Fetches the gemini response from server
    460 # Parameters: proto, host, port and path
    461 # Spec draft is here: https://gemini.circumlunar.space/docs/specification.html
    462 fetch() {
    463 	if [ ! "$1" = "gemini" ]
    464 	then
    465 		echo "Only gemini links are supported."
    466 		echo "Type a key to continue."
    467 		read -r i <&1
    468 		read -r proto host port path <<- EOF
    469 			$(getprevious)
    470 		EOF
    471 		url="$proto://$host:$port/$path"
    472 		url="${url:-$homepage}"
    473 		debug "previous page: $url"
    474 		return
    475 	fi
    476 
    477 	debug "requesting $1://$2:$3/$4$5"
    478 	
    479 	# set title
    480 	printf '\033]2;%s\007' "astro (c): $2/$4"
    481 	
    482 	echo "$1 $2 $3 $4 $5" >> "$histfile"
    483 
    484 	certfile=""
    485 	if [ -f "$certdir/$2.crt" ] && [ -f "$certdir/$2.key" ]
    486 	then
    487 		certfile="-cert \"$certdir/$2.crt\" -key \"$certdir/$2.key\""
    488 		debug "using client cert for domain: $certfile"
    489 	fi
    490 
    491 	port=$( [ "$3" = "1965" ] || ":$3" )
    492 	url="$1://$2$port/$4$5"
    493 	[ "$trace" ] && echo "url: $url" >> "$tracefile"
    494 
    495 	stop
    496 	echo "$url" | eval openssl s_client \
    497 		-connect "$2:$3" "$certfile" -crlf -quiet \
    498 		-ign_eof 2> /dev/null > "$pagefile"
    499 	stop "openssl fetch"
    500 	stop
    501 
    502 	printf '\033]2;%s\007' "astro (r): $2/$4"
    503 
    504 	# First line is status and meta information
    505 	read -r status meta < "$pagefile"
    506 	status="$(echo "$status" | tr -d '\r\n')"
    507 	meta="$(echo "$meta" | tr -d '\r\n')"
    508 	sed -i '1d' "$pagefile"
    509 	stop "status extract"
    510 	debug "response status - meta: $status - $meta"
    511 
    512 	# Validate
    513 	case "$status" in
    514 		10)
    515 			echo "Input needed: $meta" >&2
    516 			echo "Please provide the input:" >&2
    517 			read -r input <&1
    518 			url="$1://$2:$3/$4?$(urlencode "$input")"
    519 			return 0
    520 			;;
    521 		11)
    522 			echo "Sensitive input needed: $meta" >&2
    523 			read -r input <&1
    524 			url="$1://$2:$3/$4?$(urlencode "$input")"
    525 			return 0
    526 			;;
    527 		30|31)
    528 			# Redirect
    529 			debug "redirecting to: $meta"
    530 
    531 			# shellcheck disable=SC2046
    532 			read -r proto host port path <<- EOF
    533 				$(oldhost="$2" oldpath="$4" url="$meta" parseurl)
    534 			EOF
    535 			url="$proto://$host:$port/$path"
    536 			return 0
    537 			;;
    538 		40)
    539 			echo "Temporary failure" >&2
    540 			echo "Type a key to continue. "
    541 			read -r i <&1
    542 			return 3
    543 			;;
    544 		41)
    545 			return 4
    546 			;;
    547 		42)
    548 			return 5
    549 			;;
    550 		43)
    551 			return 6
    552 			;;
    553 		44)
    554 			return 7
    555 			;;
    556 		50|51)
    557 			echo "Page not found!" >&2
    558 			echo "Type a key to return to previous page."
    559 			read -r i <&1
    560 			read -r proto host port path <<- EOF
    561 				$(getprevious)
    562 			EOF
    563 			url="$proto://$host:$port/$path"
    564 			debug "previous page: $url"
    565 			return 0
    566 			;;
    567 		52)
    568 			return 10
    569 			;;
    570 		53)
    571 			return 11
    572 			;;
    573 		59)
    574 			echo "Bad request: $meta" >&2
    575 			echo "Type a key to continue."
    576 			read -r i <&1
    577 			return 12
    578 			;;
    579 		60)
    580 			printf "client certificate required, to create a client cert use the following command:\n\n"
    581 			printf "\topenssl req -x509 -newkey rsa:4096 -keyout %s/%s.key -out %s/%s.crt -days 36500 -nodes\n\n" "$certdir" "$2" "$certdir" "$2"
    582 			printf "press 'return' to reload the page or 'b' to go back to the previous page:\n"
    583 			read -r in <&1
    584 			if [ "$in" = "b" ]
    585 			then
    586 				read -r proto host port path <<- EOF
    587 					$(getprevious)
    588 				EOF
    589 				url="$proto://$host:$port/$path"
    590 			else
    591 				url="$1://$2:$3/$4?$5"
    592 			fi
    593 			return 0
    594 			;;
    595 		61)
    596 			return 14
    597 			;;
    598 		62)
    599 			return 15
    600 			;;
    601 	esac
    602 
    603 	# Success
    604 	oldhost="$2"
    605 	oldpath="$4"
    606 
    607 	# Set charset
    608 	charset="$(echo "$meta" | grep -i "charset=" | sed -e 's/.*charset=\([^;]\+\).*/\1/Ig')"
    609 	case "$charset" in
    610 		"iso-8859-1" | "ISO-8859-1") charset="iso8859" ;;
    611 		"utf-8" | "UTF-8" | "") charset="utf8" ;;
    612 		"us-ascii" | "US-ASCII") charset="ascii" ;;
    613 	esac
    614 	debug "charset: $charset"
    615 
    616 	printf '\033]2;%s\007' "astro (t): $2/$4"
    617 	case $meta in
    618 		"text/gemini"* | "") typesetgmi < "$pagefile" > "$pagefile.gmi"; mv "$pagefile.gmi" "$pagefile" ;;
    619 		*) ;;
    620 	esac 
    621 
    622 	debug "starting pager"
    623 	printf '\033]2;%s\007' "astro (l): $2/$4"
    624 	pager "$pagefile"
    625 
    626 	debug "new url: $url"
    627 }
    628 
    629 # files are handled differently
    630 if [ "$file" ] && [ -f "$file" ]
    631 then
    632 	typesetgmi < "$file" > "$pagefile"
    633 	pager "$pagefile"
    634 fi
    635 
    636 # first request
    637 url="${url:-$homepage}"
    638 while :
    639 do
    640 	printf '\033]2;%s\007' "astro (w)"
    641 
    642 	# shellcheck disable=SC2046
    643 	fetch $(parseurl)
    644 done
    645