Files
Panama/bin/panama-webapp
Gabriel Brown 05fd5346db Turn a website into an application, and notice the charger
Two of Section E's small wins.

panama-webapp gives a site its own icon, its own window with no browser
chrome, and its own launcher entry, which is most of what "installed"
means in practice and what both macOS and Windows now ship. It scrapes
the site's apple-touch-icon, falls back twice, and never fails an
install over a favicon: a web app with a generic icon still works.
Names are slugged, so "../../../../tmp/pwn" lands inside the
applications directory as tmp-pwn rather than anywhere else, and
remove refuses anything without the marker it writes -- sharing a name
with a real application must not delete that application. A browser
that cannot do app mode is told so rather than handed something that
opens an ordinary window and pretends.

The charger now announces itself through StatusEvents, which was
already the right layer and only wanted a producer. Ambient priority,
so Do Not Disturb quiets it, because a charger is exactly what DND is
for. A critically low battery is published at a priority DND does not
silence, because the one message you must not miss is the one saying
the machine is about to stop. Both join the existing silent-startup
window rather than announcing the state they found.

The keyboard-layout toast the plan also listed is deliberately not
built. Hyprland exposes the active keymap but not a change event
Quickshell already consumes, so it would need either polling or new
event plumbing, and this machine has one layout and could not test it.
2026-08-22 06:33:19 -04:00

195 lines
6.9 KiB
Bash
Executable File

#!/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 '<link[^>]+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" <<ENTRY
[Desktop Entry]
Type=Application
Name=$name
Comment=$url
Exec=$browser --app=$url
Icon=$icon
Terminal=false
Categories=Network;
StartupWMClass=$browser
X-Panama-WebApp=$url
ENTRY
command -v update-desktop-database >/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 <url> [name]
panama-webapp list
panama-webapp remove <name>
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