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'