d681792148
`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>
297 lines
13 KiB
Bash
297 lines
13 KiB
Bash
#!/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
|
|
}
|