Surface unmanaged .app bundles during sync as Markdown report

`ssr sync` now scans /Applications (and ~/Applications, configurable via
SSR_APP_DIRS) for top-level .app bundles that are neither Mac App Store
installs (Contents/_MASReceipt/receipt) nor Homebrew Cask installs, and
writes <backup_dir>/unmanaged-apps.md alongside the Brewfile. This closes
a long-standing gap in the restore story: such apps need manual reinstall
on a fresh machine, and were previously invisible to the sync workflow.

Cask detection runs fully offline against the local Caskroom and unions
four complementary strategies:

  1. Caskroom walk for .app symlinks/bundles under
     $(brew --caskroom)/<cask>/<version>/.
  2. `app "Foo.app"` declarations parsed from .rb formula receipts.
  3. `name "Foo"` field from .rb receipts (handles pkg-based casks like
     Microsoft Edge, Teams, Zoom, Google Drive, etc., that never drop
     a .app under the caskroom).
  4. `"name":["Foo"]` field from modern .json receipts (multi-line aware).

For each unmanaged app the report includes version and bundle id from the
Info.plist plus a best-effort installer link, in preference order:

  1. Sparkle SUFeedURL → derive the vendor homepage from the URL host.
  2. Bundle-id prefix match against a curated vendor map (Adobe,
     Microsoft, Docker, Cursor, Anthropic, OpenAI, JetBrains,
     GitKraken, Signal, Zoom, Telegram, Slack, Synology, Jabra, ...).
  3. DuckDuckGo search fallback (`<App name> macOS download`).

Toggles in ssr.conf.example: SSR_SCAN_UNMANAGED_APPS=true (default),
SSR_APP_DIRS=/Applications:~/Applications.

ssr-status gains a one-line summary referencing the report file.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 18:04:30 +02:00
parent a4465f37c6
commit d681792148
5 changed files with 351 additions and 1 deletions
+296
View File
@@ -0,0 +1,296 @@
#!/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 <name>` 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)/<cask>/<version>/<App>.app. Matches the basename
# of what gets dropped into /Applications.
# 2. Formula `app` declarations: each cask's .metadata/<v>/<t>/Casks/<token>.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 "<Product>"` in both .rb and .json receipts that typically
# matches the eventual /Applications/<Product>.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)/<cask>/<version>/<App>.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/<Product>.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://<domain> as homepage
# 2. Known vendor prefix on CFBundleIdentifier → official download page
# 3. DuckDuckGo search query
# Returns "<link>\t<source-label>".
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 "<App> macOS download".
link="https://duckduckgo.com/?q=$(ssr::apps::_urlencode "$name macOS download")"
printf '%s\t%s' "$link" "Search"
}
# Print a TSV row "version<TAB>bundle_id<TAB>sparkle_url<TAB>basename" 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 <backup_dir>/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
}