diff --git a/bin/omarchy-webapp-install b/bin/omarchy-webapp-install index acfdf858..d976622d 100755 --- a/bin/omarchy-webapp-install +++ b/bin/omarchy-webapp-install @@ -13,6 +13,18 @@ safe_icon_name() { | sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//' } +require_plain_name() { + # The name becomes a filename. A slash would turn it into directory levels, so + # the launcher lands somewhere omarchy-webapp-remove cannot address and the app + # is stuck in the launcher; a leading ../ leaves the applications directory + # altogether. Refuse rather than silently renaming what the user typed -- most + # often it is a URL entered in the name field. + if [[ $1 == */* ]]; then + echo "App name cannot contain '/': $1" + exit 1 + fi +} + icon_name_from_ref() { local ref="$1" local name @@ -68,6 +80,7 @@ fetch_site_icon() { if (( $# < 3 )); then echo -e "\e[32mLet's create a new web app you can start with the app launcher.\n\e[0m" APP_NAME=$(gum input --prompt "Name> " --placeholder "My favorite web app") + require_plain_name "$APP_NAME" APP_URL=$(gum input --prompt "URL> " --placeholder "https://example.com") if [[ ! $APP_URL =~ ^[a-zA-Z][a-zA-Z0-9+.-]*: ]]; then APP_URL="https://$APP_URL" @@ -104,6 +117,8 @@ if [[ -z $APP_NAME || -z $APP_URL ]]; then exit 1 fi +require_plain_name "$APP_NAME" + if [[ -z $ICON_REF ]]; then ICON_VALUE=$(safe_icon_name "$APP_NAME") mkdir -p "$ICON_DIR" @@ -132,8 +147,9 @@ fi EXEC_COMMAND="${CUSTOM_EXEC:-omarchy-launch-webapp $APP_URL}" # Create application .desktop file -DESKTOP_FILE="$HOME/.local/share/applications/$APP_NAME.desktop" -mkdir -p "$(dirname "$DESKTOP_FILE")" +DESKTOP_DIR="$HOME/.local/share/applications" +DESKTOP_FILE="$DESKTOP_DIR/$APP_NAME.desktop" +mkdir -p "$DESKTOP_DIR" cat >"$DESKTOP_FILE" </dev/null) +# The launcher matching a chosen name, or empty when nothing was indexed under +# it (an app removed between the scan and the pick, say). +path_for_web_app() { + local wanted="$1" i + for i in "${!WEB_APPS[@]}"; do + if [[ ${WEB_APPS[$i]} == "$wanted" ]]; then + printf '%s\n' "${WEB_APP_PATHS[$i]}" + return 0 + fi + done +} + +if (( $# == 0 )); then if ((${#WEB_APPS[@]})); then mapfile -t SORTED_WEB_APPS < <(printf '%s\n' "${WEB_APPS[@]}" | sort) APP_NAME=$(omarchy-menu-select "Select web app to remove" "${SORTED_WEB_APPS[@]}" -- --width 520 --maxheight 520) @@ -34,7 +51,8 @@ if [[ -z $APP_NAME ]]; then fi icon_name=$(printf '%s\n' "$APP_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^[:alnum:]]\+/-/g; s/^-//; s/-$//') -rm -f "$DESKTOP_DIR/$APP_NAME.desktop" +desktop_file=$(path_for_web_app "$APP_NAME") +rm -f "${desktop_file:-$DESKTOP_DIR/$APP_NAME.desktop}" rm -f "$ICON_DIR/$icon_name.png" "$ICON_DIR/$APP_NAME.png" "$OLD_ICON_DIR/$APP_NAME.png" if [[ ${OMARCHY_REMOVE_NOTIFY:-true} != "false" ]]; then diff --git a/test/shell.d/webapp-name-test.sh b/test/shell.d/webapp-name-test.sh new file mode 100644 index 00000000..903f851a --- /dev/null +++ b/test/shell.d/webapp-name-test.sh @@ -0,0 +1,126 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT +mkdir -p "$tmp_dir/bin" "$tmp_dir/home" + +for stub in gtk-update-icon-cache update-desktop-database omarchy-notification-send; do + printf '#!/bin/bash\n:\n' >"$tmp_dir/bin/$stub" + chmod +x "$tmp_dir/bin/$stub" +done + +run_install() { + HOME="$tmp_dir/home" PATH="$tmp_dir/bin:$PATH" \ + "$ROOT/bin/omarchy-webapp-install" "$@" +} + +run_remove() { + HOME="$tmp_dir/home" PATH="$tmp_dir/bin:$PATH" OMARCHY_REMOVE_NOTIFY=false \ + "$ROOT/bin/omarchy-webapp-remove" "$@" +} + +apps_dir="$tmp_dir/home/.local/share/applications" +icons_dir="$tmp_dir/home/.local/share/icons/hicolor/256x256/apps" + +# A URL typed into the name field is the reported way in. Every slash used to +# become a directory level, leaving a launcher nothing could address. Assert on +# the message: creating the launcher directly in the applications directory +# already makes the redirect fail on its own, so a bare non-zero exit would pass +# just as well with no validation at all. +output=$(run_install "http://example.test/oops" "https://example.com" hey 2>&1) && + fail "webapp install rejects a name containing a slash" +[[ $output == *"App name cannot contain '/'"* ]] || + fail "webapp install says why it refused a slashed name" "$output" +[[ -e "$apps_dir/http:" ]] && + fail "webapp install does not create a directory from a slashed name" +pass "webapp install rejects a name that would nest the launcher" + +# The name was a path fragment until something said otherwise, so ../ climbed +# out of the applications directory entirely and wrote wherever it landed. +if run_install "../../../../escaped" "https://example.com" hey >/dev/null 2>&1; then + fail "webapp install rejects a name that climbs out of the applications directory" +fi +[[ -e "$tmp_dir/escaped.desktop" ]] && + fail "webapp install writes no launcher outside the applications directory" +pass "webapp install refuses a name that would escape the applications directory" + +# The interactive prompt reads the name long before it is used as a path, and +# fetches the site icon in between. Rejecting only at the write leaves that icon +# behind in the user's icon theme, once per attempt. +mkdir -p "$tmp_dir/ibin" +cp "$tmp_dir/bin"/* "$tmp_dir/ibin/" +cat >"$tmp_dir/ibin/gum" <<'STUB' +#!/bin/bash +count_file="${GUM_STUB_COUNT:?}" +count=$(cat "$count_file" 2>/dev/null || echo 0) +count=$((count + 1)) +echo "$count" >"$count_file" +if (( count == 1 )); then + echo "http://example.test/oops" +else + echo "https://example.com" +fi +STUB +cat >"$tmp_dir/ibin/curl" <<'STUB' +#!/bin/bash +# Answer any download with a real PNG so the icon fetch reports success. +out="" +prev="" +for arg in "$@"; do + [[ $prev == "-o" ]] && out="$arg" + prev="$arg" +done +if [[ -n $out ]]; then + printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' | base64 -d >"$out" +fi +STUB +chmod +x "$tmp_dir/ibin/gum" "$tmp_dir/ibin/curl" + +if HOME="$tmp_dir/home" PATH="$tmp_dir/ibin:$PATH" \ + GUM_STUB_COUNT="$tmp_dir/gum-count" \ + "$ROOT/bin/omarchy-webapp-install" >/dev/null 2>&1; then + fail "interactive webapp install rejects a name containing a slash" +fi +if compgen -G "$icons_dir/*.png" >/dev/null; then + fail "interactive webapp install downloads no icon for a name it refuses" \ + "$(ls "$icons_dir")" +fi +pass "webapp install refuses a slashed name before fetching its icon" + +# A normal name still installs and removes. +run_install "Example App" "https://example.com" hey >/dev/null +[[ -f "$apps_dir/Example App.desktop" ]] || + fail "webapp install writes the launcher for an ordinary name" +run_remove "Example App" >/dev/null +[[ -f "$apps_dir/Example App.desktop" ]] && + fail "webapp remove deletes the launcher it installed" +pass "webapp install and remove round-trip an ordinary name" + +# Anything installed by an older version can still be nested. Removal has to +# reach it, which a path rebuilt from the displayed name never could. +mkdir -p "$apps_dir/http:/127.0.0.1:4000" +cat >"$apps_dir/http:/127.0.0.1:4000/.desktop" <<'DESKTOP' +[Desktop Entry] +Name=http://127.0.0.1:4000 +Exec=omarchy-launch-webapp https://127.0.0.1:4000 +Type=Application +DESKTOP + +# This is the name the picker shows for that file: the script strips .desktop +# from the path and then takes the basename, which lands on the directory. +run_remove "127.0.0.1:4000" >/dev/null +[[ -f "$apps_dir/http:/127.0.0.1:4000/.desktop" ]] && + fail "webapp remove deletes a launcher left nested by an older install" +pass "webapp remove reaches a nested legacy launcher" + +# Removing by name on a machine with no applications directory yet must stay +# quiet: omarchy-remove-gaming-xbox-cloud calls it without hiding stderr. +noise=$(HOME="$tmp_dir/empty" PATH="$tmp_dir/bin:$PATH" OMARCHY_REMOVE_NOTIFY=false \ + "$ROOT/bin/omarchy-webapp-remove" "Xbox Cloud Gaming" 2>&1 >/dev/null) +[[ -n $noise ]] && + fail "webapp remove stays quiet with no applications directory" "$noise" +pass "webapp remove stays quiet when there is no applications directory"