#!/bin/sh # Obel Mono 1.001 installer for macOS and Linux. # https://typographic.systems/obel-mono # # Usage: # curl -fsSL https://typographic.systems/install.sh | sh # curl -fsSL https://typographic.systems/install.sh | sh -s -- --uninstall # # What it does: # 1. Downloads two zips over HTTPS from https://typographic.systems/download/ # into a temporary directory that is removed on exit: # ObelMono-1.001-ttf.zip Obel Mono # ObelMonoMasked-1.001-ttf.zip Obel Mono Masked (masks secrets by default) # 2. Verifies each zip against the SHA-256 pinned below. On any mismatch # it stops before installing anything. # 3. Installs the four variable fonts from each zip's variable/ directory, # ObelMono-Italic[wght].ttf ObelMono[wght].ttf # ObelMonoMasked-Italic[wght].ttf ObelMonoMasked[wght].ttf # into # macOS ~/Library/Fonts # Linux ${XDG_DATA_HOME:-~/.local/share}/fonts/obel-mono, then runs fc-cache # replacing any ObelMono*[wght].ttf already there. Each font is copied # beside its final name and renamed into place, so an interrupted or # failed run never leaves a half-written font. # # Requires curl, unzip (or python3), and sha256sum or shasum. Refuses to run # under sudo, contacts only typographic.systems, and writes only to the # fonts directory and its own temporary directory. Output is plain text when # stdout is not a terminal or NO_COLOR is set. Windows: download the zip from # https://typographic.systems/obel-mono#download # Behave as POSIX sh when piped to zsh. if [ -n "${ZSH_VERSION:-}" ]; then emulate sh; fi set -eu umask 022 VERSION=1.001 BASE=https://typographic.systems/download ZIP_1=ObelMono-1.001-ttf.zip SHA_1=3a0ce658b14ffd79728ae06937e8a6f707563cf46e32a081e0a34f58d3faf762 FAM_1='Obel Mono' SIZE_1=2802053 ZIP_2=ObelMonoMasked-1.001-ttf.zip SHA_2=09fbe4ff23ac453cd25dd87cd473238def328f6afa3f9e90300d4f28118c184c FAM_2='Obel Mono Masked' SIZE_2=2802399 FONTS=4 INSTALL_URL=https://typographic.systems/install.sh ZIP_PAGE=https://typographic.systems/obel-mono#download CONTACT=hello@typographic.systems WIDTH=72 # Terminal capabilities: color, UTF-8, and whether to animate. setup_output() { esc=$(printf '\033') utf8=0 case ${LC_ALL:-${LC_CTYPE:-${LANG:-}}} in *[Uu][Tt][Ff]-8* | *[Uu][Tt][Ff]8*) utf8=1 ;; esac [ "$(uname -s 2>/dev/null || true)" = Darwin ] && utf8=1 tty=0 [ -t 1 ] && tty=1 ACCENT='' DIM='' BOLD='' RESET='' CR='' CLEAR='' if [ "$tty" = 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-dumb}" != dumb ]; then case ${COLORTERM:-} in truecolor | 24bit) ACCENT="${esc}[38;2;255;60;5m" DIM="${esc}[38;2;138;138;136m" ;; *) case ${TERM:-} in *256*) ACCENT="${esc}[38;5;202m" DIM="${esc}[38;5;245m" ;; *) ACCENT="${esc}[31m" DIM="${esc}[2m" ;; esac ;; esac BOLD="${esc}[1m" RESET="${esc}[0m" fi if [ "$tty" = 1 ]; then CR=$(printf '\r') CLEAR="${esc}[2K"; fi # UTF-8 glyphs are written as octal escapes so this file stays ASCII. if [ "$utf8" = 1 ]; then OK=$(printf '\342\234\223') # U+2713 check mark FAIL=$(printf '\342\234\225') # U+2715 multiplication x NONE=$(printf '\342\200\223') # U+2013 en dash BAR=$(printf '\342\224\201') # U+2501 box heavy horizontal SPIN=$(printf '\342\240\213 \342\240\231 \342\240\271 \342\240\270 \342\240\274 \342\240\264 \342\240\246 \342\240\247 \342\240\207 \342\240\217') # The Typographic Systems asterisk (the favicon's three bars at 0, 60 and # 120 degrees) in braille dots, three lines of seven cells. Braille is the # spinner's own character set, so any terminal that shows the spinner # shows the mark (Ghostty and kitty draw it; others borrow a system font). MARK1=$(printf '\342\242\200\342\243\204\342\241\200\342\243\277\342\242\200\342\243\240\342\241\200') MARK2=$(printf ' \342\243\211\342\243\277\342\243\277\342\243\277\342\243\211 ') MARK3=$(printf '\342\240\210\342\240\213\342\240\201\342\243\277\342\240\210\342\240\231\342\240\201') else OK='+' FAIL='x' NONE='-' BAR='=' SPIN='. o O o' MARK1=' \|/ ' MARK2=' /|\ ' MARK3=' ' fi # shellcheck disable=SC2086 # SPIN is a space-separated list of frames frames=$(printf '%s\n' $SPIN | wc -l | tr -d ' ') # Progress needs sub-second sleep (macOS, GNU coreutils and busybox have it). animate=0 if [ "$tty" = 1 ] && sleep 0.01 2>/dev/null; then animate=1; fi } say() { printf '%s\n' "$*"; } repeat() { n=$1 out='' while [ "$n" -gt 0 ]; do out="$out$2"; n=$((n - 1)); done printf '%s' "$out" } # Print a path with $HOME abbreviated to ~. # shellcheck disable=SC2088 # a literal ~, for display tilde() { case $1 in "$HOME"/*) printf '~/%s' "${1#"$HOME"/}" ;; *) printf '%s' "$1" ;; esac } human_size() { if [ "$1" -lt 1048576 ]; then printf '%s KB' $((($1 + 512) / 1024)) else tenths=$((($1 * 10 + 524288) / 1048576)) printf '%s.%s MB' $((tenths / 10)) $((tenths % 10)) fi } # First 16 and last 8 characters of a digest. abbrev() { printf '%s...%s' "${1%"${1#????????????????}"}" "${1#"${1%????????}"}"; } header() { say '' say " $ACCENT$MARK1$RESET ${BOLD}Obel Mono$RESET${1:+ $1}" say " $ACCENT$MARK2$RESET ${DIM}a free font from Typographic Systems$RESET" say " $ACCENT$MARK3$RESET ${DIM}typographic.systems$RESET" say '' } # A detail line under a status line, in the detail column. detail() { say "$(repeat 24 ' ')$DIM$1$RESET"; } # status MARK_COLOR MARK LABEL DETAIL status() { printf '%s%s %s%s%s %s%-18s%s %s\n' "$CR" "$CLEAR" "$1" "$2" "$RESET" "$BOLD" "$3" "$RESET" "$4" } wrap() { printf '%s\n' "$1" | fold -s -w $((WIDTH - 6)) | sed 's/ *$//; s/^/ /'; } # die SUMMARY [LINE...] # Print SUMMARY on the current step (or on its own line), then each LINE: # "> text" is a command, ". text" is dimmed detail, anything else is prose. # Writes to stderr and exits 1. die() { { if [ -n "$step" ]; then status "$ACCENT" "$FAIL" "$step" "$ACCENT$1$RESET" else printf '%s%s %s%s %s%s%s\n' "$CR" "$CLEAR" "$ACCENT" "$FAIL" "$BOLD" "$1" "$RESET" fi shift blank=1 for line in "$@"; do case $line in '> '*) say ''; say " $DIM\$$RESET $BOLD${line#> }$RESET"; blank=1 ;; '. '*) [ "$blank" = 1 ] && say ''; printf '%s' "$DIM"; wrap "${line#. }"; printf '%s' "$RESET"; blank=0 ;; *) say ''; wrap "$line"; blank=1 ;; esac done say '' } >&2 exit 1 } # Print the install command for PACKAGE if the package manager is known. install_hint() { [ "$os" = macos ] && return 1 for pm in apt-get:'sudo apt install' dnf:'sudo dnf install' pacman:'sudo pacman -S' \ zypper:'sudo zypper install' apk:'sudo apk add' xbps-install:'sudo xbps-install'; do if command -v "${pm%%:*}" >/dev/null 2>&1; then printf '%s %s' "${pm#*:}" "$1" return 0 fi done return 1 } # require TOOL PURPOSE [PACKAGE] require() { command -v "$1" >/dev/null 2>&1 && return 0 if hint=$(install_hint "${3:-$1}"); then die "$1 is missing" "The installer needs $1 $2. Install it, then run the same line again:" \ "> $hint" '. Nothing was installed.' fi die "$1 is missing" "The installer needs $1 $2. Install it with your package manager, then run the same line again." \ '. Nothing was installed.' } # download_failed CURL_EXIT FILE HTTP_STATUS CURL_MESSAGE download_failed() { detail=". curl: ${4#curl: }" case $1 in 5 | 6 | 7) die "can't reach typographic.systems" \ "Check that you're online and that no proxy or firewall is blocking it, then run the same line again." \ "$detail" '. Nothing was installed.' ;; 28) die "the connection timed out" "Three attempts in a row stalled or couldn't connect. Check your connection, then run the same line again." \ "$detail" '. Nothing was installed.' ;; 22) if [ "$3" = 404 ]; then die "not on the server" \ "typographic.systems doesn't have $2 right now. A new release may be going up: try again in a minute." \ ". HTTP 404" '. Nothing was installed.' fi die "the server returned an error" "typographic.systems couldn't send $2 just now. Try again in a minute." \ "$detail" '. Nothing was installed.' ;; 35 | 51 | 53 | 54 | 58 | 59 | 60 | 66 | 77 | 83 | 90 | 91) if [ "$os" = macos ]; then fix='> softwareupdate --install --all' elif hint=$(install_hint ca-certificates); then fix="> $hint" else fix='Update the ca-certificates package with your package manager.'; fi die "TLS verification failed" \ "curl couldn't verify typographic.systems and stopped before downloading. Your CA certificates may be out of date, or a proxy is intercepting the connection. Update them, then run the same line again:" \ "$fix" "$detail" '. Nothing was installed.' ;; 23) die "couldn't write the download" "Free some disk space, then run the same line again." \ "$detail" '. Nothing was installed.' ;; *) die "the download failed" "Run the same line again." "$detail" '. Nothing was installed.' ;; esac } # Worth another attempt: connection refused, cut short, stalled, or a # server-side error (CURL_EXIT HTTP_STATUS). transient() { case $1 in 7 | 18 | 28 | 52 | 55 | 56 | 92) return 0 ;; 22) case $2 in 408 | 429 | 5??) return 0 ;; esac ;; esac return 1 } sha256_of() { if [ "$sha_tool" = shasum ]; then shasum -a 256 "$1"; else sha256sum "$1"; fi } # extract ZIP MEMBER_PREFIX DIR extract() { if [ "$unzip_tool" = unzip ]; then unzip -q -o "$1" "$2*" -d "$3" else python3 -I -c 'import sys, zipfile z = zipfile.ZipFile(sys.argv[1]) z.extractall(sys.argv[3], [n for n in z.namelist() if n.startswith(sys.argv[2])])' "$1" "$2" "$3" fi } # progress BYTES TOTAL progress() { cells=16 filled=$(($1 * 16 / $2)) [ "$filled" -gt "$cells" ] && filled=$cells printf '%s%s%s%s%s %s%s of %s%s' "$ACCENT" "$(repeat "$filled" "$BAR")" "$DIM" "$(repeat $((cells - filled)) "$BAR")" "$RESET" \ "$DIM" "$(human_size "$1")" "$(human_size "$2")" "$RESET" } # fetch ZIP SHA256 FAMILY BYTES # Download over HTTPS only (no redirects followed), verify, then extract # the zip's variable/ directory into $tmp/fonts. The shell opens the output # file rather than curl, so a sandboxed curl (the Ubuntu snap) still works. # A stall of 30 seconds counts as a failure; transient failures are retried # twice. fetch() { step=$3 attempt=1 while :; do curl --fail --silent --show-error --proto '=https' --tlsv1.2 \ --connect-timeout 20 --speed-limit 1 --speed-time 30 \ "$BASE/$1" >"$tmp/$1" 2>"$tmp/curl_err" & curl_pid=$! if [ "$animate" = 1 ]; then i=0 while kill -0 "$curl_pid" 2>/dev/null; do i=$((i % frames + 1)) # shellcheck disable=SC2086 # SPIN is a space-separated list of frames frame=$(printf '%s\n' $SPIN | sed -n "${i}p") bytes=$(wc -c <"$tmp/$1" | tr -d ' ') printf '%s%s %s%s%s %s%-18s%s %s' "$CR" "$CLEAR" "$ACCENT" "$frame" "$RESET" "$BOLD" "$3" "$RESET" "$(progress "$bytes" "$4")" sleep 0.06 done fi curl_exit=0 wait "$curl_pid" || curl_exit=$? curl_pid='' [ "$curl_exit" = 0 ] && break http=$(sed -n 's/.*returned error: \([0-9][0-9][0-9]\).*/\1/p' "$tmp/curl_err" | head -n 1) if [ "$attempt" -lt 3 ] && transient "$curl_exit" "$http"; then sleep "$attempt" attempt=$((attempt + 1)) continue fi download_failed "$curl_exit" "$1" "$http" "$(head -n 1 "$tmp/curl_err")" done actual=$(sha256_of "$tmp/$1") actual=${actual%% *} if [ "$actual" != "$2" ]; then # A zip starts with "PK"; anything else is usually a Wi-Fi login page # or a proxy's answer. if [ "$(dd if="$tmp/$1" bs=2 count=1 2>/dev/null)" = PK ]; then why="This is not the file the release published. Usually the download was cut short: run the same line again. If it fails twice, send the lines below to $CONTACT." else why="The server's answer wasn't a zip. A Wi-Fi login page or a proxy may be in the way: open any website in a browser, then run the same line again." fi die "checksum mismatch" "$why" \ ". expected $(abbrev "$2")" ". received $(abbrev "$actual")" '. Nothing was installed.' fi if ! extract "$tmp/$1" "${1%.zip}/variable/" "$tmp/fonts" 2>"$tmp/unzip_err"; then die "couldn't extract the zip" \ "The download is verified, but $unzip_tool couldn't extract it. If the disk is full, free some space, then run the same line again. Or install from the zip by hand:" \ ". $ZIP_PAGE" ". $unzip_tool: $(tail -n 1 "$tmp/unzip_err")" '. Nothing was installed.' fi status "$ACCENT" "$OK" "$3" "$DIM$(printf '%6s' "$(human_size "$4")") sha-256 ${2%"${2#????????}"} verified$RESET" step='' } # Remove this family's variable fonts from $dest, except those named in # DIR when given; the count goes in $removed. Static fonts a user copied by # hand (e.g. ObelMono-Bold.ttf) are left alone. remove_installed() { removed=0 [ -d "$dest" ] || return 0 for f in "$dest"/ObelMono*'[wght].ttf'; do [ -e "$f" ] || continue if [ -n "${1:-}" ] && [ -e "$1/${f##*/}" ]; then continue; fi rm -f "$f" removed=$((removed + 1)) done } # Rebuild fontconfig's cache for the fonts directory only (Linux). refresh_cache() { [ "$os" = linux ] || return 0 command -v fc-cache >/dev/null 2>&1 || return 0 for d in "$dest" "${dest%/*}"; do if [ -d "$d" ]; then fc-cache -f "$d" >/dev/null 2>&1 || true; return 0; fi done } usage() { header "$VERSION" say " Installs Obel Mono and Obel Mono Masked, roman and italic, into" say " ~/Library/Fonts on macOS or ~/.local/share/fonts/obel-mono on Linux." say " No sudo." say '' say " ${DIM}Install$RESET curl -fsSL $INSTALL_URL | sh" say " ${DIM}Uninstall$RESET curl -fsSL $INSTALL_URL \\" say " | sh -s -- --uninstall" say " ${DIM}Source$RESET $INSTALL_URL" say '' } # curl runs in the background, where a non-interactive sh ignores SIGINT, # so it is stopped here explicitly. cleanup() { if [ -n "$curl_pid" ]; then kill "$curl_pid" 2>/dev/null || true; fi if [ "$staged" = 1 ]; then rm -f "$dest"/.ObelMono*".$$.part"; fi if [ -n "$tmp" ]; then rm -rf "$tmp"; fi } interrupted() { code=130 { case $phase in swap) status "$ACCENT" "$FAIL" "Stopped" "${ACCENT}partway through installing$RESET" say '' wrap "Some fonts may be the previous version. Run the same line again to finish." ;; installed) status "$ACCENT" "$OK" "Installed" "Obel Mono and Obel Mono Masked" detail "in $dest_shown; font cache not refreshed" say '' wrap "Apps will find them after a restart or the next login." code=0 ;; *) status "$DIM" "$NONE" "Stopped" "${DIM}nothing was installed$RESET" ;; esac say '' } >&2 exit "$code" } main() { setup_output step='' staged=0 phase='' mode=install os=other curl_pid='' tmp='' dest='' for arg in "$@"; do case $arg in --uninstall) mode=uninstall ;; -h | --help) usage; return 0 ;; *) header "$VERSION" die "unknown option: $arg" "Supported options are --uninstall and --help." \ "> curl -fsSL $INSTALL_URL | sh -s -- --help" ;; esac done if [ "$mode" = uninstall ]; then header; else header "$VERSION"; fi case ${HOME:-} in /?*) ;; *) die "HOME is not set" \ "There is no fonts directory to install into. Run the same line from a login shell." \ '. Nothing was installed.' ;; esac # Under sudo the fonts would belong to root, and a later run as the # user could neither update nor remove them. if [ "$(id -u)" = 0 ] && [ -n "${SUDO_USER:-}" ]; then die "run it without sudo" \ "Fonts install for your user, so the installer never needs sudo. Under sudo they would belong to root, and you couldn't update or remove them later." \ "> curl -fsSL $INSTALL_URL | sh" '. Nothing was changed.' fi # The XDG spec says to ignore a relative XDG_DATA_HOME. data_home=$HOME/.local/share case ${XDG_DATA_HOME:-} in /*) data_home=$XDG_DATA_HOME ;; esac kernel=$(uname -s 2>/dev/null || true) case $kernel in Darwin) os=macos dest=$HOME/Library/Fonts ;; Linux) os=linux dest=$data_home/fonts/obel-mono ;; MINGW* | MSYS* | CYGWIN*) die "Windows is not supported" \ "This installer is for macOS and Linux. On Windows, download the zip, open its variable/ folder, select the four fonts, then right-click and choose Install." \ ". $ZIP_PAGE" ;; *) die "${kernel:-this system} is not supported" \ "This installer is for macOS and Linux. Download the zip and install the fonts in its variable/ folder." \ ". $ZIP_PAGE" ;; esac dest_shown=$(tilde "$dest") if [ "$mode" = uninstall ]; then remove_installed rm -f "$dest"/.ObelMono*.part if [ "$os" = linux ]; then rmdir "$dest" 2>/dev/null || true; fi refresh_cache if [ "$removed" = 0 ]; then status "$DIM" "$NONE" "Nothing to remove" "${DIM}no Obel Mono fonts in $dest_shown$RESET" else status "$ACCENT" "$OK" "Uninstalled" "Obel Mono and Obel Mono Masked" detail "$removed font files removed from $dest_shown" say '' say " ${DIM}Running apps keep them until they restart.$RESET" fi say '' return 0 fi require curl "to download the fonts" if command -v unzip >/dev/null 2>&1; then unzip_tool=unzip elif command -v python3 >/dev/null 2>&1; then unzip_tool=python3 else require unzip "to extract the downloads" fi if [ "$os" = macos ] && command -v shasum >/dev/null 2>&1; then sha_tool=shasum elif command -v sha256sum >/dev/null 2>&1; then sha_tool=sha256sum elif command -v shasum >/dev/null 2>&1; then sha_tool=shasum else require sha256sum "to verify the downloads" coreutils fi trap cleanup EXIT trap interrupted INT trap 'exit 1' HUP TERM tmp=$(mktemp -d "${TMPDIR:-/tmp}/obel-mono.XXXXXX") || die "couldn't create a temporary directory" \ "mktemp failed in ${TMPDIR:-/tmp}. Free some disk space, then run the same line again." \ '. Nothing was installed.' fetch "$ZIP_1" "$SHA_1" "$FAM_1" "$SIZE_1" fetch "$ZIP_2" "$SHA_2" "$FAM_2" "$SIZE_2" step=Installing mkdir "$tmp/new" mv "$tmp"/fonts/*/variable/*.ttf "$tmp/new/" count=0 for f in "$tmp"/new/*.ttf; do [ -e "$f" ] && count=$((count + 1)) done [ "$count" -eq "$FONTS" ] || die "expected $FONTS fonts, found $count" \ "The release archives don't contain what this installer expects. This is a packaging bug: please report it to $CONTACT." \ '. Nothing was installed.' if ! mkdir -p "$dest" 2>/dev/null || [ ! -d "$dest" ] || [ ! -w "$dest" ]; then die "can't write to $dest_shown" "Your user can't create or write to this folder. Check who owns it:" \ "> ls -ld $dest_shown" '. Nothing was installed.' fi # Stage every font beside its final name first: if this fails, the # installed fonts are untouched. staged=1 for f in "$tmp"/new/*.ttf; do if ! cp "$f" "$dest/.${f##*/}.$$.part" 2>"$tmp/cp_err"; then die "copy failed" "If the disk is full, free some space, then run the same line again." \ ". cp: $(head -n 1 "$tmp/cp_err")" '. Your installed fonts were not changed.' fi done # Rename each into place (atomic within one directory), then remove # variable fonts from earlier releases that this one no longer has. previous=0 for f in "$dest"/ObelMono*'[wght].ttf'; do [ -e "$f" ] && previous=$((previous + 1)) done phase=swap for f in "$tmp"/new/*.ttf; do if ! mv -f "$dest/.${f##*/}.$$.part" "$dest/${f##*/}" 2>"$tmp/mv_err"; then die "couldn't move ${f##*/} into place" "Run the same line again." \ ". mv: $(head -n 1 "$tmp/mv_err")" fi done remove_installed "$tmp/new" staged=0 phase=installed refresh_cache # Say what Font Book and font menus will show: two families, every weight. done_label=Installed if [ "$previous" -gt 0 ]; then done_label=Updated; fi status "$ACCENT" "$OK" "$done_label" "Obel Mono and Obel Mono Masked" detail "9 weights each, roman and italic" detail "$dest_shown" step='' if [ "$os" = macos ]; then code_apps='Ghostty, kitty, WezTerm, VS Code, Zed' masked_apps='apps with no font settings' masked_note='masks API keys; in iTerm2, turn on Use ligatures' else code_apps='Ghostty, kitty, WezTerm, VS Code' masked_apps='Zed, and apps with no font settings' masked_note='masks API keys with no setting' fi say '' say ' Restart your terminal, then choose a font:' say '' say " $BOLD$(printf '%-19s' "Obel Mono")$RESET $code_apps" say " $(repeat 19 ' ') ${DIM}turn on ss01 to mask API keys$RESET" say " $BOLD$(printf '%-19s' "Obel Mono Masked")$RESET $masked_apps" say " $(repeat 19 ' ') ${DIM}$masked_note$RESET" say '' if [ "$os" = linux ] && grep -qi microsoft /proc/version 2>/dev/null; then say " ${ACCENT}WSL$RESET These fonts are for Linux apps. Windows Terminal uses" say " the Windows font store: install the zip there." say " $DIM$ZIP_PAGE$RESET" say '' fi say " ${DIM}Specimen$RESET typographic.systems/obel-mono" say " ${DIM}Uninstall$RESET curl -fsSL $INSTALL_URL \\" say " | sh -s -- --uninstall" say '' } main "$@"