diff --git a/README.md b/README.md index 5778891..bc34aa6 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -145 of them, under `tests/`. Run the lot, or a subset by pattern: +146 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/bin/panama-webapp b/bin/panama-webapp new file mode 100755 index 0000000..03ff53d --- /dev/null +++ b/bin/panama-webapp @@ -0,0 +1,194 @@ +#!/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 diff --git a/config/dot/quickshell/services/DeviceEvents.qml b/config/dot/quickshell/services/DeviceEvents.qml index 75ed3bf..3db9948 100644 --- a/config/dot/quickshell/services/DeviceEvents.qml +++ b/config/dot/quickshell/services/DeviceEvents.qml @@ -14,6 +14,8 @@ Singleton { property bool outputInitialized: false property bool bluetoothInitialized: false property bool kdeInitialized: false + property bool powerInitialized: false + property bool previousAcOnline: true property string previousOutput: "" property string previousBluetooth: "" property bool previousKdeReachable: false @@ -83,9 +85,59 @@ Singleton { root.previousBluetooth = root.bluetoothNames; root.previousKdeReachable = KdeConnect.phoneReachable; root.previousKdeName = root.kdeName; + root.previousAcOnline = Battery.acOnline; root.outputInitialized = true; root.bluetoothInitialized = true; root.kdeInitialized = true; + root.powerInitialized = true; + } + } + + // The charger. Announced the way every other device transition here is: + // once, on the change, and never as a description of the resting state. + // Ambient priority, so Do Not Disturb quiets it -- a charger is exactly + // the kind of thing DND is for. + // + // Absent entirely on a desktop, because Battery.acChanged only fires where + // there is a battery to be on. + Connections { + target: Battery + function onAcChanged(online: bool): void { + if (!root.powerInitialized) { + root.previousAcOnline = online; + return; + } + if (online === root.previousAcOnline) + return; + root.previousAcOnline = online; + StatusEvents.publish({ + key: "device-power", + glyph: online ? "\u{F06A5}" : "\u{F0084}", + title: online ? "Charging" : "On battery", + detail: Battery.available ? Math.round(Battery.percent) + "%" : "", + priority: StatusEvents.ambientPriority + }); + } + } + + // Running out is not ambient. Published at a priority Do Not Disturb does + // not silence, because the one notification you must not miss is the one + // saying the machine is about to stop. + Connections { + target: Battery + function onCriticalChanged(): void { + if (!root.powerInitialized || !Battery.critical) + return; + StatusEvents.publish({ + key: "battery-critical", + glyph: "\u{F0083}", + title: "Battery critically low", + detail: Math.round(Battery.percent) + "% remaining", + tone: "danger", + priority: StatusEvents.criticalPriority, + actionId: "open-settings", + actionData: "power" + }); } } diff --git a/config/local/share/vicinae/scripts/install-web-app b/config/local/share/vicinae/scripts/install-web-app new file mode 100755 index 0000000..c30930a --- /dev/null +++ b/config/local/share/vicinae/scripts/install-web-app @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# @vicinae.schemaVersion 1 +# @vicinae.title Install Web App +# @vicinae.mode silent +# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg +# @vicinae.description Turn a website into an application with its own icon and window. +# @vicinae.keywords ["web app", "install", "site", "pwa", "shortcut", "browser"] +# @vicinae.argument1 { "type": "text", "placeholder": "https://example.com" } +# @vicinae.argument2 { "type": "text", "placeholder": "name (optional)" } + +exec "$HOME/.local/share/Panama/bin/panama-webapp" install "$1" "$2" diff --git a/tests/quickshell/curated-events-policy-contract b/tests/quickshell/curated-events-policy-contract index e499723..721616d 100755 --- a/tests/quickshell/curated-events-policy-contract +++ b/tests/quickshell/curated-events-policy-contract @@ -20,4 +20,29 @@ rg -q 'id: discoverySettle' "$devices" || fail 'device discovery has no silent i ! rg -Uq 'Component\.onCompleted:[^{\n]*\{[^}]*outputInitialized = true' "$devices" || fail 'output monitoring announces initial discovery' rg -Uq 'function clearFixture\(\): void \{[^}]*initialized = false' "$privacy" || fail 'leaving a fixture can emit a false privacy transition' +# ── The power transitions ──────────────────────────────────────────────────── +# +# The charger is a device transition like any other, and joins the same silent +# startup window: without it, plugging in during login would announce itself +# while the desktop was still drawing. +# +# The two priorities are the actual policy. Do Not Disturb silences anything +# below importantPriority, which is right for a charger and wrong for a battery +# that is about to run out -- the one message you must not miss is the one +# saying the machine is about to stop. +rg -q 'key: "device-power"' "$devices" \ + || fail 'nothing announces the charger being connected or removed' +rg -q 'key: "battery-critical"' "$devices" \ + || fail 'nothing announces a critically low battery' +# Bounded any-character rather than "not a brace": the glyph values are +# \u{...} escapes, so a brace appears between the key and its priority. +rg -Uq 'key: "device-power"(.|\n){0,400}?priority: StatusEvents\.ambientPriority' "$devices" \ + || fail 'the charger is not ambient, so Do Not Disturb would not quiet it' +rg -Uq 'key: "battery-critical"(.|\n){0,400}?priority: StatusEvents\.criticalPriority' "$devices" \ + || fail 'a critically low battery is silenced by Do Not Disturb' +rg -q 'root.previousAcOnline = Battery.acOnline;' "$devices" \ + || fail 'power monitoring does not seed its previous state in the settle window' +! rg -Uq 'Component\.onCompleted:[^{\n]*\{[^}]*powerInitialized = true' "$devices" \ + || fail 'power monitoring announces its initial state' + printf 'curated-events policy: PASS\n' diff --git a/tests/quickshell/panama-commands-contract b/tests/quickshell/panama-commands-contract index 28bf142..f526e84 100755 --- a/tests/quickshell/panama-commands-contract +++ b/tests/quickshell/panama-commands-contract @@ -77,7 +77,7 @@ declare -a standalone=( lock-screen suspend-system log-out reboot-system power-off remind-me list-reminders pick-color switch-window force-quit-window kill-process ssh-hosts recent-files - copy-password keyboard-shortcuts show-welcome + copy-password keyboard-shortcuts show-welcome install-web-app ) # Generated commands must match their source. A stale command dispatches to a diff --git a/tests/setup/webapp-contract b/tests/setup/webapp-contract new file mode 100755 index 0000000..6b7ac2a --- /dev/null +++ b/tests/setup/webapp-contract @@ -0,0 +1,158 @@ +#!/usr/bin/env bash + +# A website, as an application. +# +# The dock and the launcher had nothing to feed them but installed packages, +# while both macOS and Windows now turn a site into something with its own +# icon and its own window. This is that, in about a hundred lines of shell. +# +# What must hold: +# +# 1. The entry it writes is a valid desktop entry that opens the site in app +# mode. A malformed one is invisible rather than broken, which is worse. +# 2. A name cannot escape the applications directory. The name comes from a +# person typing into a launcher box, so "../../../.bashrc" is a thing it +# will eventually be handed. +# 3. Remove only ever removes its own. Sharing a name with a real +# application must not delete that application. +# 4. A missing icon does not fail the install. A web app with a generic icon +# still works; an install that failed because a favicon 404'd does not. +# 5. A browser that cannot do app mode is refused rather than given +# something that opens a normal window and pretends. +# +# Runs against a throwaway XDG_DATA_HOME, so nothing here touches the real +# applications directory. Network calls are stubbed. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +webapp="$repo_dir/bin/panama-webapp" + +findings=() +note() { findings+=("$1"); } + +[[ -x "$webapp" ]] || { printf 'webapp contract: %s is not executable\n' "$webapp" >&2; exit 1; } + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +data="$work/data" +stub="$work/bin" +mkdir -p "$data/applications" "$stub" + +# curl fails for everything, which is the icon-less case. Item 4 says an +# install must survive it. +cat >"$stub/curl" <<'STUB' +#!/usr/bin/env bash +exit 1 +STUB +chmod +x "$stub/curl" + +# A chromium-family default browser. +cat >"$stub/xdg-settings" <<'STUB' +#!/usr/bin/env bash +printf 'chromium.desktop\n' +STUB +chmod +x "$stub/xdg-settings" +cat >"$stub/chromium" <<'STUB' +#!/usr/bin/env bash +exit 0 +STUB +chmod +x "$stub/chromium" +mkdir -p "$data/applications" +cat >"$data/applications/chromium.desktop" <&1; } + +# ── 1 & 4. Install writes a valid entry, even with no icon ────────────────── + +output="$(run install https://app.example.com "Example App")" +status=$? +(( status == 0 )) || note "install failed when the icon could not be fetched: $output" + +entry="$data/applications/panama-webapp-example-app.desktop" +[[ -f "$entry" ]] || note 'install did not write a desktop entry' + +if [[ -f "$entry" ]]; then + grep -q '^Type=Application$' "$entry" || note 'the entry is not an Application' + grep -q '^Name=Example App$' "$entry" || note 'the entry does not carry the name given' + grep -q -- '--app=https://app.example.com' "$entry" \ + || note 'the entry does not open the site in app mode, so it would open an ordinary browser window' + grep -q '^X-Panama-WebApp=' "$entry" \ + || note 'the entry is not marked as a Panama web app, so remove cannot tell it from a real application' + if command -v desktop-file-validate >/dev/null 2>&1; then + desktop-file-validate "$entry" >/dev/null 2>&1 \ + || note 'the entry does not pass desktop-file-validate, so a launcher may ignore it' + fi +fi + +# ── 2. A hostile name cannot write outside the directory ──────────────────── + +run install https://example.com "../../../../tmp/panama-escape" >/dev/null 2>&1 +[[ -e "$work/panama-escape.desktop" || -e "/tmp/panama-escape.desktop" ]] \ + && note 'a name containing path separators wrote outside the applications directory' +# It should have landed as a slug inside the directory, or been refused. +escaped="$(find "$data/applications" -name '*escape*' | head -1)" +if [[ -n "$escaped" ]]; then + [[ "$(dirname "$escaped")" == "$data/applications" ]] \ + || note 'a hostile name escaped the applications directory' +fi + +# A name with nothing usable in it is refused rather than producing a file +# called ".desktop". +run install https://example.com "///" >/dev/null 2>&1 \ + && note 'a name with no usable characters was accepted' + +# ── 3. Remove only removes its own ────────────────────────────────────────── + +# A real application that happens to share a name. +cat >"$data/applications/panama-webapp-decoy.desktop" <<'DECOY' +[Desktop Entry] +Type=Application +Name=Decoy +Exec=/bin/true +DECOY +run remove "Decoy" >/dev/null 2>&1 \ + && note 'remove deleted an entry that is not a Panama web app' +[[ -f "$data/applications/panama-webapp-decoy.desktop" ]] \ + || note 'remove deleted a file it should have refused to touch' + +run remove "Example App" >/dev/null 2>&1 || note 'remove failed on a web app it installed' +[[ -f "$entry" ]] && note 'remove left the entry behind' + +run remove "Not Installed" >/dev/null 2>&1 \ + && note 'removing something that was never installed reported success' + +# ── 5. A browser without app mode is refused ──────────────────────────────── + +cat >"$stub/xdg-settings" <<'STUB' +#!/usr/bin/env bash +printf 'firefox.desktop\n' +STUB +cat >"$data/applications/firefox.desktop" <<'STUB' +[Desktop Entry] +Name=Firefox +Exec=/usr/bin/firefox %U +STUB +output="$(run install https://example.com "Firefox Test" 2>&1)" +status=$? +(( status != 0 )) || note 'a browser with no app mode was accepted, so the entry would open an ordinary window' +grep -qi 'app' <<<"$output" || note 'the refusal does not explain why' + +# ── The launcher command ──────────────────────────────────────────────────── + +command_file="$repo_dir/config/local/share/vicinae/scripts/install-web-app" +[[ -x "$command_file" ]] || note 'there is no launcher command to install a web app' +grep -q 'argument1' "$command_file" \ + || note 'the launcher command takes no URL argument' + +if (( ${#findings[@]} > 0 )); then + printf 'webapp contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'webapp contract: PASS\n'