#!/usr/bin/env bash # Creates a Chromium webapp .desktop entry with favicon as icon. # Usage: create-webapp.sh [display-name] set -euo pipefail usage() { echo "Usage: $(basename "$0") [display-name]" echo echo " Fetches the page title and favicon, then writes a .desktop" echo " entry that opens the URL in Chromium app-window mode (no tabs/address bar)." exit 1 } [[ $# -lt 1 ]] && usage URL="$1" CUSTOM_NAME="${2:-}" # Prepend https:// if the user omitted the scheme, so curl/grep work correctly. [[ "$URL" != http://* && "$URL" != https://* ]] && URL="https://$URL" # Extract just the origin (scheme + host) — needed as a base for resolving # relative favicon URLs later (e.g. "/icons/logo.png" → "https://example.com/icons/logo.png"). BASE_URL=$(echo "$URL" | grep -oE 'https?://[^/]+') # Strip scheme and optional "www." to get a clean domain used as a fallback name. DOMAIN=$(echo "$BASE_URL" | sed -E 's|https?://(www\.)?||') echo "Fetching: $URL" # -sL : silent + follow redirects. -A : spoof a browser User-Agent so sites # don't return a stripped mobile/bot page with no or <link> tags. # The "|| echo ''" prevents pipefail from killing the script on network errors. PAGE_HTML=$(curl -sL --max-time 15 \ -A "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" \ "$URL" 2>/dev/null || echo "") # Resolve app name from <title> or argument if [[ -n "$CUSTOM_NAME" ]]; then APP_NAME="$CUSTOM_NAME" else # Python inline: parse the <title> tag and HTML-unescape entities like & # so the .desktop Name field looks clean. Falls back to DOMAIN on failure. APP_NAME=$(echo "$PAGE_HTML" | python3 -c ' import sys, re, html as html_mod content = sys.stdin.read() m = re.search(r"<title[^>]*>([^<]+)", content, re.IGNORECASE) print(html_mod.unescape(m.group(1).strip()) if m else "", end="") ' 2>/dev/null || true) APP_NAME="${APP_NAME:-$DOMAIN}" fi # Build a filesystem-safe identifier from the app name. # tr '[:upper:]' '[:lower:]' : lowercase everything # tr -cs 'a-z0-9' '-' : replace any run of non-alphanumeric chars with '-' # sed 's/^-*//;s/-*$//' : trim leading/trailing hyphens # Used for both the .desktop filename and the StartupWMClass (WM grouping key). SAFE_ID=$(echo "$APP_NAME" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '-' | sed 's/^-*//;s/-*$//') [[ -z "$SAFE_ID" ]] && SAFE_ID="$DOMAIN" # Find the best favicon declared in tags. # The Python snippet ranks candidates by their declared pixel size (sizes="NxN") # and picks the largest — "any" is treated as 999 to rank SVG/vector icons highest. # urljoin resolves relative paths against BASE_URL, matching browser behavior. FAVICON_URL=$(echo "$PAGE_HTML" | python3 -c ' import sys, re from urllib.parse import urljoin base = sys.argv[1] content = sys.stdin.read() candidates = [] for link in re.findall(r"]+?)/??>", content, re.IGNORECASE | re.DOTALL): rel_m = re.search(r"rel=[\"'"'"']([^\"'"'"']+)[\"'"'"']", link, re.IGNORECASE) if not rel_m or "icon" not in rel_m.group(1).lower(): continue href_m = re.search(r"href=[\"'"'"']([^\"'"'"']+)[\"'"'"']", link, re.IGNORECASE) if not href_m: continue size = 0 sizes_m = re.search(r"sizes=[\"'"'"']([^\"'"'"']+)[\"'"'"']", link, re.IGNORECASE) if sizes_m: val = sizes_m.group(1).lower().split("x")[0] try: size = 999 if val == "any" else int(val) except ValueError: pass candidates.append((size, href_m.group(1))) if candidates: candidates.sort(key=lambda x: x[0], reverse=True) print(urljoin(base, candidates[0][1]), end="") ' "$BASE_URL" 2>/dev/null || true) # Fall back to the conventional /favicon.ico path when no tag was found. FAVICON_URL="${FAVICON_URL:-$BASE_URL/favicon.ico}" echo "Favicon: $FAVICON_URL" # Store webapp icons separately from system icons to keep ~/.local/share/icons clean. ICON_DIR="$HOME/.local/share/icons/webapps" mkdir -p "$ICON_DIR" # Use a temp file so a failed download doesn't leave a partial file at the final path. TMP=$(mktemp /tmp/webapp-icon.XXXXXX) trap 'rm -f "$TMP"' EXIT # Default: fall through to the chromium themed icon if download/convert fails. ICON_PATH="chromium" # fallback if curl -sL --max-time 10 -A "Mozilla/5.0" -o "$TMP" "$FAVICON_URL" && [[ -s "$TMP" ]]; then # Prefer ImageMagick: convert normalizes the format (handles multi-frame .ico # via [0]) and caps the size at 128x128 without upscaling (\> flag). if command -v convert &>/dev/null \ && convert "${TMP}[0]" -resize 128x128\> "${ICON_DIR}/${SAFE_ID}.png" 2>/dev/null; then ICON_PATH="${ICON_DIR}/${SAFE_ID}.png" echo "Icon: $ICON_PATH (PNG via ImageMagick)" else # Fallback: keep the file as-is, just fix the .ico MIME type alias so the # extension matches what XDG icon loaders expect. MIME=$(file -b --mime-type "$TMP") EXT="${MIME##*/}" [[ "$EXT" == "x-icon" || "$EXT" == "vnd.microsoft.icon" ]] && EXT="ico" cp "$TMP" "${ICON_DIR}/${SAFE_ID}.${EXT}" ICON_PATH="${ICON_DIR}/${SAFE_ID}.${EXT}" echo "Icon: $ICON_PATH ($MIME)" fi else echo "Warning: could not fetch favicon — using chromium default icon" fi # Write .desktop file per freedesktop.org Desktop Entry Specification. # chromium --app= opens the site in an app window (no address bar/tabs), # making it feel like a native application. DESKTOP_DIR="$HOME/.local/share/applications" mkdir -p "$DESKTOP_DIR" DESKTOP_FILE="${DESKTOP_DIR}/webapp-${SAFE_ID}.desktop" cat > "$DESKTOP_FILE" << DESKTOP [Desktop Entry] Version=1.0 Type=Application Name=${APP_NAME} Exec=chromium --app=${URL} Icon=${ICON_PATH} Terminal=false Categories=Network;WebBrowser; StartupWMClass=${SAFE_ID} DESKTOP # Mark executable so the file manager and XDG launchers treat it as launchable. chmod +x "$DESKTOP_FILE" echo echo "Created : $DESKTOP_FILE" echo " Name : $APP_NAME" echo " URL : $URL" echo " Icon : $ICON_PATH"