#!/usr/bin/env bash # lib/apps.sh — detect .app bundles not tracked by Homebrew Cask or the # Mac App Store. Used by `ssr sync` to surface manually-installed apps # that would otherwise be missed on restore. # # Detection rules (in order): # 1. MAS-installed: bundle contains Contents/_MASReceipt/receipt # 2. Cask-installed: bundle filename matches the .app artifact of any # installed cask (via `brew list --cask ` output) # 3. Otherwise: unmanaged # Top-level .app bundles in the configured directories. # Default scans /Applications and ~/Applications (user-installed apps). ssr::apps::list_installed() { local dirs="${SSR_APP_DIRS:-/Applications:$HOME/Applications}" local IFS=: local d for d in $dirs; do [[ -d "$d" ]] || continue # -maxdepth 1 keeps us out of nested bundles like # /Applications/Microsoft Office/Word.app — we want top-level only. find "$d" -maxdepth 1 -type d -name '*.app' -print 2>/dev/null done } # Newline-separated list of .app basenames owned by any installed cask. # Reads only on-disk caskroom data — no network, no `brew info` API calls. # # Three complementary strategies (union, deduped): # 1. Caskroom walk: every cask stores a symlink or a copy of its .app at # $(brew --caskroom)///.app. Matches the basename # of what gets dropped into /Applications. # 2. Formula `app` declarations: each cask's .metadata///Casks/.rb # declares `app "Foo.app"` for app-style artifacts; catches casks where # the caskroom entry was pruned but the receipt remains. # 3. Formula `name` field (case-insensitive .app suffix match): pkg-based # casks (Edge, Teams, Zoom, Google Drive, …) install via .pkg and never # place a .app under the caskroom. They do, however, declare a canonical # `name ""` in both .rb and .json receipts that typically # matches the eventual /Applications/.app filename. # --- Helper strategies for ssr::apps::list_cask_apps --- # Each helper always returns 0 and prints zero or more "Foo.app" basenames. # Running them in dedicated subshells with `set +e +o pipefail` insulates # them from the caller's strict mode (lib/common.sh sets -euo pipefail). # 1) Caskroom walk: $(brew --caskroom)///.app _ssr::apps::cask_strategy_1() ( set +e +o pipefail find "$1" -mindepth 3 -maxdepth 3 -name '*.app' \ \( -type l -o -type d \) 2>/dev/null \ | awk -F'/' '{print $NF}' ) # 2) `app "Foo.app"` from .rb formula receipts _ssr::apps::cask_strategy_2() ( set +e +o pipefail find "$1" -path '*/.metadata/*/Casks/*.rb' -type f 2>/dev/null \ -exec grep -hE "^[[:space:]]*app[[:space:]]+[\"']" {} + 2>/dev/null \ | sed -E "s/^[[:space:]]*app[[:space:]]+[\"']([^\"']+)[\"'].*$/\1/" \ | awk -F'/' '{print $NF}' ) # 3a) `name "Foo"` from .rb formulas → "Foo.app" _ssr::apps::cask_strategy_3a() ( set +e +o pipefail find "$1" -path '*/.metadata/*/Casks/*.rb' -type f 2>/dev/null \ -exec grep -hE "^[[:space:]]*name[[:space:]]+[\"']" {} + 2>/dev/null \ | sed -E "s/^[[:space:]]*name[[:space:]]+[\"']([^\"']+)[\"'].*$/\1.app/" ) # 3b) "name":[...] from .json receipts → "Foo.app" # Modern brew writes pretty-printed JSON, so the `"name"` key and its value # can span multiple lines. Collapse each file to a single line first, then # extract the first quoted entry inside the "name" array. _ssr::apps::cask_strategy_3b() ( set +e +o pipefail while IFS= read -r f; do [[ -n "$f" ]] || continue tr '\n' ' ' < "$f" 2>/dev/null \ | grep -oE '"name"[[:space:]]*:[[:space:]]*\[[[:space:]]*"[^"]+"' \ | sed -E 's/.*"([^"]+)"$/\1.app/' done < <(find "$1" -path '*/.metadata/*/Casks/*.json' -type f 2>/dev/null) ) # Newline-separated list of .app basenames owned by any installed cask. # Reads only on-disk caskroom data — no network, no `brew info` API calls. # # Three complementary strategies, union deduped: # 1. Caskroom walk (covers casks that drop a .app via `app` artifact). # 2. .rb formula `app` declaration (covers casks where caskroom was pruned). # 3. Formula `name` field, case-insensitive (covers pkg-based casks # like Microsoft Edge, Teams, Zoom, Google Drive where no .app is # placed under the caskroom but the canonical product name matches # the eventual /Applications/.app). ssr::apps::list_cask_apps() { command -v brew >/dev/null 2>&1 || return 0 local caskroom caskroom="$(brew --caskroom 2>/dev/null)" || return 0 [[ -d "$caskroom" ]] || return 0 { _ssr::apps::cask_strategy_1 "$caskroom" _ssr::apps::cask_strategy_2 "$caskroom" _ssr::apps::cask_strategy_3a "$caskroom" _ssr::apps::cask_strategy_3b "$caskroom" } | sort -u return 0 } ssr::apps::is_mas() { [[ -e "$1/Contents/_MASReceipt/receipt" ]] } # --- Metadata helpers ------------------------------------------------------- # Read one scalar key from a plist; print nothing if absent/unreadable. ssr::apps::_plist_get() { plutil -extract "$2" raw -o - "$1" 2>/dev/null } # RFC3986 component encoding for query strings — pure bash, no python/perl. # Spaces become %20; safe characters pass through. ssr::apps::_urlencode() { local s="$1" i c out="" for (( i=0; i<${#s}; i++ )); do c="${s:i:1}" case "$c" in [a-zA-Z0-9.~_-]) out+="$c" ;; *) out+="$(printf '%%%02X' "'$c")" ;; esac done printf '%s' "$out" } # Best-effort installer/vendor link for a given .app and bundle id. # Preference order: # 1. Sparkle SUFeedURL → derive https:// as homepage # 2. Known vendor prefix on CFBundleIdentifier → official download page # 3. DuckDuckGo search query # Returns "\t". ssr::apps::_installer_link() { local name="$1" bid="$2" sparkle="$3" local link source # 1) Sparkle feed wins when present. if [[ -n "$sparkle" && "$sparkle" =~ ^https?:// ]]; then link="$(printf '%s' "$sparkle" | awk -F'/' '{print $1"//"$3}')" source="Sparkle" printf '%s\t%s' "$link" "$source" return fi # 2) Known vendor maps keyed by bundle-id prefix. case "$bid" in com.adobe.*) link="https://helpx.adobe.com/download-install.html"; source="Adobe" ;; com.microsoft.*) link="https://www.microsoft.com/microsoft-365/mac"; source="Microsoft" ;; com.google.*|com.google.Chrome*) link="https://www.google.com/intl/en/$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]' | tr -d ' ')/" source="Google" ;; com.docker.*) link="https://www.docker.com/products/docker-desktop/"; source="Docker" ;; com.anthropic.*|ai.anthropic.*|com.electron.claude*) link="https://claude.ai/download"; source="Anthropic" ;; com.openai.*|com.electron.codex*) link="https://openai.com/chatgpt/desktop/"; source="OpenAI" ;; com.todesktop.*-cursor*|com.cursor.*|com.electron.cursor*) link="https://www.cursor.com/downloads"; source="Cursor" ;; com.tinyspeck.*|com.slack.*) link="https://slack.com/downloads/mac"; source="Slack" ;; us.zoom.*) link="https://zoom.us/download"; source="Zoom" ;; com.tdesktop.*) link="https://desktop.telegram.org/"; source="Telegram" ;; org.whispersystems.*|net.whispersystems.*) link="https://signal.org/download/macos/"; source="Signal" ;; com.jetbrains.*) link="https://www.jetbrains.com/products/"; source="JetBrains" ;; com.axosoft.gitkraken*) link="https://www.gitkraken.com/download"; source="GitKraken" ;; com.scootersoftware.*) link="https://www.scootersoftware.com/download.php"; source="Scooter" ;; com.paloaltonetworks.GlobalProtect*) link="https://docs.paloaltonetworks.com/globalprotect"; source="Palo Alto" ;; com.jabra.*) link="https://www.jabra.com/support/jabra-direct"; source="Jabra" ;; com.synology.*) link="https://www.synology.com/support/download"; source="Synology" ;; *) link=""; source="Search" ;; esac if [[ -n "$link" ]]; then printf '%s\t%s' "$link" "$source" return fi # 3) Fallback: DuckDuckGo lucky-search for " macOS download". link="https://duckduckgo.com/?q=$(ssr::apps::_urlencode "$name macOS download")" printf '%s\t%s' "$link" "Search" } # Print a TSV row "versionbundle_idsparkle_urlbasename" for # a single .app. Missing fields are empty strings (not "—") so callers can # substitute their own placeholders. ssr::apps::_app_meta() { local app="$1" local plist="$app/Contents/Info.plist" local version="" bid="" sparkle="" if [[ -f "$plist" ]]; then version="$(ssr::apps::_plist_get "$plist" CFBundleShortVersionString)" [[ -z "$version" ]] && version="$(ssr::apps::_plist_get "$plist" CFBundleVersion)" bid="$(ssr::apps::_plist_get "$plist" CFBundleIdentifier)" sparkle="$(ssr::apps::_plist_get "$plist" SUFeedURL)" fi printf '%s\t%s\t%s\t%s\n' "$version" "$bid" "$sparkle" "${app##*/}" } # Print unmanaged .app paths, one per line. Stable order (sorted). ssr::apps::list_unmanaged() { local cask_apps_lc app name name_lc # Case-insensitive comparison: lowercase the cask-app set once. cask_apps_lc="$(ssr::apps::list_cask_apps | tr '[:upper:]' '[:lower:]')" \ || cask_apps_lc="" { while IFS= read -r app; do [[ -n "$app" ]] || continue ssr::apps::is_mas "$app" && continue name="${app##*/}" name_lc="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')" # grep -Fx: fixed string, full-line — avoids substring confusion # for apps with overlapping names ("Slack.app" vs "Slack Mini.app"). if [[ -n "$cask_apps_lc" ]] \ && printf '%s\n' "$cask_apps_lc" | grep -qFx "$name_lc"; then continue fi printf '%s\n' "$app" done < <(ssr::apps::list_installed) } | sort return 0 } # Escape a string for safe use inside a Markdown table cell. ssr::apps::_md_escape() { # `|` would break columns; `\` and backticks could nest into code spans. printf '%s' "$1" | sed 's/\\/\\\\/g; s/|/\\|/g; s/`/\\`/g' } # Write the report as Markdown at /unmanaged-apps.md and echo # the path on stdout. The table includes a best-effort installer link per # app derived from Sparkle feeds, known vendor bundle prefixes, or a search # fallback (see ssr::apps::_installer_link). ssr::apps::write_report() { local backup_dir="$1" local out="$backup_dir/unmanaged-apps.md" local tmp count app meta name version bid sparkle linkinfo link source local name_md bid_md ssr::ensure_dir "$backup_dir" tmp="$(mktemp)" local apps apps="$(ssr::apps::list_unmanaged)" count=0 [[ -n "$apps" ]] && count="$(printf '%s\n' "$apps" | wc -l | tr -d ' ')" { printf '# Unmanaged macOS applications\n\n' # Leading `-` in a format string is parsed as a printf option; use %s. printf '%s\n' "- **host:** \`$(hostname -s)\`" printf '%s\n' "- **generated:** \`$(ssr::ts)\`" printf '%s\n\n' "- **count:** $count" printf 'These `.app` bundles are **not** tracked by Homebrew Cask or the Mac App Store and need to be reinstalled manually after a restore. Links point to the vendor download page (Sparkle feed, known vendor map, or a web search as a last resort).\n\n' if [[ "$count" -eq 0 ]]; then printf '_No unmanaged apps detected._\n' else printf '| App | Version | Bundle ID | Source |\n' printf '|---|---|---|---|\n' while IFS= read -r app; do [[ -n "$app" ]] || continue meta="$(ssr::apps::_app_meta "$app")" version="$(printf '%s' "$meta" | cut -f1)" bid="$( printf '%s' "$meta" | cut -f2)" sparkle="$(printf '%s' "$meta" | cut -f3)" name="${app##*/}"; name="${name%.app}" linkinfo="$(ssr::apps::_installer_link "$name" "$bid" "$sparkle")" link="${linkinfo%% *}" source="${linkinfo##* }" name_md="$(ssr::apps::_md_escape "$name")" bid_md="$( ssr::apps::_md_escape "$bid")" printf '| [%s](%s) | %s | `%s` | %s |\n' \ "$name_md" \ "$link" \ "${version:-—}" \ "${bid_md:-—}" \ "$source" done <<< "$apps" fi printf '\n---\n_Generated by `ssr sync`._\n' } > "$tmp" mv -f "$tmp" "$out" printf '%s\n' "$out" return 0 }