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.
This commit is contained in:
@@ -114,7 +114,7 @@ docs/ Settings reference, and the design specs behind the work
|
|||||||
|
|
||||||
## Tests
|
## 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
|
```sh
|
||||||
panama test # everything
|
panama test # everything
|
||||||
|
|||||||
Executable
+194
@@ -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 '<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
|
||||||
@@ -14,6 +14,8 @@ Singleton {
|
|||||||
property bool outputInitialized: false
|
property bool outputInitialized: false
|
||||||
property bool bluetoothInitialized: false
|
property bool bluetoothInitialized: false
|
||||||
property bool kdeInitialized: false
|
property bool kdeInitialized: false
|
||||||
|
property bool powerInitialized: false
|
||||||
|
property bool previousAcOnline: true
|
||||||
property string previousOutput: ""
|
property string previousOutput: ""
|
||||||
property string previousBluetooth: ""
|
property string previousBluetooth: ""
|
||||||
property bool previousKdeReachable: false
|
property bool previousKdeReachable: false
|
||||||
@@ -83,9 +85,59 @@ Singleton {
|
|||||||
root.previousBluetooth = root.bluetoothNames;
|
root.previousBluetooth = root.bluetoothNames;
|
||||||
root.previousKdeReachable = KdeConnect.phoneReachable;
|
root.previousKdeReachable = KdeConnect.phoneReachable;
|
||||||
root.previousKdeName = root.kdeName;
|
root.previousKdeName = root.kdeName;
|
||||||
|
root.previousAcOnline = Battery.acOnline;
|
||||||
root.outputInitialized = true;
|
root.outputInitialized = true;
|
||||||
root.bluetoothInitialized = true;
|
root.bluetoothInitialized = true;
|
||||||
root.kdeInitialized = 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"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
@@ -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"
|
||||||
@@ -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 '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'
|
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'
|
printf 'curated-events policy: PASS\n'
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ declare -a standalone=(
|
|||||||
lock-screen suspend-system log-out reboot-system power-off
|
lock-screen suspend-system log-out reboot-system power-off
|
||||||
remind-me list-reminders pick-color
|
remind-me list-reminders pick-color
|
||||||
switch-window force-quit-window kill-process ssh-hosts recent-files
|
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
|
# Generated commands must match their source. A stale command dispatches to a
|
||||||
|
|||||||
Executable
+158
@@ -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" <<STUB
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=Chromium
|
||||||
|
Exec=$stub/chromium %U
|
||||||
|
STUB
|
||||||
|
|
||||||
|
run() { XDG_DATA_HOME="$data" PATH="$stub:$PATH" "$webapp" "$@" 2>&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'
|
||||||
Reference in New Issue
Block a user