#!/usr/bin/env bash # A website, as an application. # # panama-webapp install https://app.example.com "Example" # panama-webapp list # panama-webapp remove "Example" # # Both macOS and Windows ship this now -- Safari's "Add to Dock", Edge's # "Install this site as an app" -- and the dock and launcher here had nothing # to feed them but installed packages. A web app gets its own icon, its own # window with no browser chrome, and its own entry in the launcher, which is # most of what "installed" means in practice. # # The desktop entry is ordinary and inspectable: it lives in # ~/.local/share/applications with everything else, and `remove` finds its own # entries by the launcher line rather than by keeping a list somewhere. # # Chromium-family browsers implement --app. Firefox does not, and there is no # honest equivalent, so a machine whose default browser is Firefox is told # rather than given something that opens a normal window and pretends. set -uo pipefail APPLICATIONS="${XDG_DATA_HOME:-$HOME/.local/share}/applications" ICONS="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/256x256/apps" LAUNCH_MARKER="panama-webapp" err() { printf 'panama-webapp: %s\n' "$*" >&2; } # A filename that cannot escape the applications directory. Everything that is # not a letter or a digit becomes a hyphen, which also makes the result # predictable enough for `remove` to find. slugify() { printf '%s' "$1" \ | tr '[:upper:]' '[:lower:]' \ | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' } # The browser to open the app window with. Follows the desktop's own default # rather than naming one, and refuses rather than degrading when that browser # cannot do app mode. resolve_browser() { local desktop exec_line binary desktop="$(xdg-settings get default-web-browser 2>/dev/null || true)" if [[ -n "$desktop" ]]; then local file for dir in "$APPLICATIONS" /usr/local/share/applications /usr/share/applications; do file="$dir/$desktop" [[ -r "$file" ]] || continue exec_line="$(sed -n 's/^Exec=//p' "$file" | head -1)" binary="$(awk '{ print $1 }' <<<"$exec_line")" break done fi [[ -n "${binary:-}" ]] || binary="$(command -v chromium || command -v google-chrome || true)" [[ -n "$binary" ]] || return 1 # Chromium-family only. The name check is crude but the alternative is # launching a browser to ask it, which is worse. case "$(basename "$binary")" in *firefox*|*librewolf*|*zen*) return 2 ;; esac printf '%s' "$binary" } # Four attempts, in descending order of how much the site had to say about it. # An icon is never worth failing an install over: a web app with the generic # icon still works. fetch_icon() { local url="$1" slug="$2" origin html href target origin="$(sed -E 's#^(https?://[^/]+).*#\1#' <<<"$url")" target="$ICONS/$slug.png" mkdir -p "$ICONS" html="$(curl -fsSL --max-time 10 "$url" 2>/dev/null || true)" href="$(grep -oiE ']+rel="[^"]*apple-touch-icon[^"]*"[^>]*>' <<<"$html" \ | grep -oiE 'href="[^"]+"' | head -1 | sed 's/href="//I; s/"$//' || true)" if [[ -n "$href" ]]; then case "$href" in http*) ;; /*) href="$origin$href" ;; *) href="$origin/$href" ;; esac curl -fsSL --max-time 10 -o "$target" "$href" 2>/dev/null && { printf '%s' "$slug"; return 0; } fi curl -fsSL --max-time 10 -o "$target" "$origin/apple-touch-icon.png" 2>/dev/null \ && { printf '%s' "$slug"; return 0; } local host host="$(sed -E 's#^https?://([^/]+).*#\1#' <<<"$url")" curl -fsSL --max-time 10 -o "$target" \ "https://www.google.com/s2/favicons?sz=256&domain=$host" 2>/dev/null \ && { printf '%s' "$slug"; return 0; } rm -f "$target" printf 'applications-internet' } cmd_install() { local url="${1:-}" name="${2:-}" [[ -n "$url" ]] || { err 'install needs a URL'; return 2; } [[ "$url" =~ ^https?:// ]] || { err 'the URL must begin with http:// or https://'; return 2; } # Default the name from the host, so `install https://app.example.com` is # enough for the common case. [[ -n "$name" ]] || name="$(sed -E 's#^https?://(www\.)?([^/]+).*#\2#' <<<"$url")" local slug; slug="$(slugify "$name")" [[ -n "$slug" ]] || { err 'that name has no usable characters in it'; return 2; } local browser status browser="$(resolve_browser)"; status=$? if (( status == 2 )); then err 'the default browser cannot open a site as its own application.' err 'Chromium-family browsers implement --app; Firefox does not.' return 1 fi [[ -n "$browser" ]] || { err 'no browser found'; return 1; } local icon; icon="$(fetch_icon "$url" "$slug")" mkdir -p "$APPLICATIONS" local entry="$APPLICATIONS/$LAUNCH_MARKER-$slug.desktop" cat >"$entry" </dev/null 2>&1 \ && update-desktop-database "$APPLICATIONS" >/dev/null 2>&1 command -v gtk-update-icon-cache >/dev/null 2>&1 \ && gtk-update-icon-cache -f -t "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" 2>/dev/null printf 'Installed %s\n' "$name" printf ' %s\n' "$entry" } cmd_list() { local entry name url shopt -s nullglob for entry in "$APPLICATIONS/$LAUNCH_MARKER-"*.desktop; do name="$(sed -n 's/^Name=//p' "$entry" | head -1)" url="$(sed -n 's/^X-Panama-WebApp=//p' "$entry" | head -1)" printf '%s\t%s\n' "$name" "$url" done } cmd_remove() { local name="${1:-}" [[ -n "$name" ]] || { err 'remove needs a name'; return 2; } local slug; slug="$(slugify "$name")" local entry="$APPLICATIONS/$LAUNCH_MARKER-$slug.desktop" # Only ever removes an entry this command installed. The prefix and the # X-Panama-WebApp key both have to be there, so a name collision with a # real application cannot delete it. [[ -f "$entry" ]] || { err "no web app named '$name'"; return 1; } grep -q '^X-Panama-WebApp=' "$entry" || { err "$entry is not a Panama web app"; return 1; } rm -f "$entry" "$ICONS/$slug.png" command -v update-desktop-database >/dev/null 2>&1 \ && update-desktop-database "$APPLICATIONS" >/dev/null 2>&1 printf 'Removed %s\n' "$name" } case "${1:-}" in install) shift; cmd_install "$@" ;; list) shift; cmd_list "$@" ;; remove) shift; cmd_remove "$@" ;; -h|--help|"") cat <<'USAGE' usage: panama-webapp install [name] panama-webapp list panama-webapp remove Turns a website into an application: its own icon, its own window with no browser chrome, and its own entry in the launcher. USAGE ;; *) err "unknown command: $1"; exit 2 ;; esac