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
+18 -1
View File
@@ -70,11 +70,25 @@ Output structure in the cloud drive:
├── BACKUP/ ├── BACKUP/
│ └── <hostname> - <hardware-uuid>/ │ └── <hostname> - <hardware-uuid>/
│ ├── Brewfile │ ├── Brewfile
── Brewfile.bak ── Brewfile.bak
│ └── unmanaged-apps.md # apps installed manually (not brew/MAS)
└── Mackup/ └── Mackup/
└── … (managed by mackup) └── … (managed by mackup)
``` ```
During each sync the unmanaged-apps scan compares every `.app` bundle in `/Applications` (and `~/Applications`) against:
1. **Mac App Store** — bundle contains `Contents/_MASReceipt/receipt`
2. **Homebrew Cask** — bundle filename matches the `.app` artifact of any installed cask
Anything not matched is written to `unmanaged-apps.md` (Markdown table, one row per app, with version, bundle id and a best-effort installer link) and printed to the sync log as a warning. Disable with `SSR_SCAN_UNMANAGED_APPS=false`.
Link selection for each app uses, in order:
1. **Sparkle feed**`SUFeedURL` from the bundle's `Info.plist` → linked vendor homepage.
2. **Known vendor map** — bundle-id prefix maps to the official download page (Adobe, Microsoft, Docker, Cursor, Anthropic, JetBrains, GitKraken, Signal, Zoom, etc.).
3. **Web search** — DuckDuckGo query for `<App name> macOS download`.
### Restore on a new machine ### Restore on a new machine
```bash ```bash
@@ -103,6 +117,8 @@ Config lives in `~/.config/ssr/ssr.conf` (override with `--config` or `$SSR_CONF
| `SSR_MACKUP_SUBDIR` | `Mackup` | Subfolder used by mackup | | `SSR_MACKUP_SUBDIR` | `Mackup` | Subfolder used by mackup |
| `SSR_BREW_UPGRADE` | `true` | Run `brew upgrade` during sync | | `SSR_BREW_UPGRADE` | `true` | Run `brew upgrade` during sync |
| `SSR_BREW_CLEANUP` | `true` | Run `brew cleanup` after sync | | `SSR_BREW_CLEANUP` | `true` | Run `brew cleanup` after sync |
| `SSR_SCAN_UNMANAGED_APPS` | `true` | List `.app` bundles not from brew/MAS during sync |
| `SSR_APP_DIRS` | `/Applications:~/Applications` | Colon-separated dirs scanned for unmanaged apps |
| `SSR_DISABLE_GATEKEEPER` | `false` | Run `spctl --global-disable` during restore (security trade-off) | | `SSR_DISABLE_GATEKEEPER` | `false` | Run `spctl --global-disable` during restore (security trade-off) |
| `SSR_OPEN_CASKS` | `false` | Open every installed cask after restore for first-run config | | `SSR_OPEN_CASKS` | `false` | Open every installed cask after restore for first-run config |
| `SSR_MACOS_DEFAULTS` | repo's `config/macos-defaults.sh` | Override path to your own defaults script | | `SSR_MACOS_DEFAULTS` | repo's `config/macos-defaults.sh` | Override path to your own defaults script |
@@ -217,6 +233,7 @@ ssr update --dry-run # show what would change, don't pull
│ ├── cloud.sh # Provider abstraction │ ├── cloud.sh # Provider abstraction
│ ├── brew.sh # Homebrew install + Brewfile ops │ ├── brew.sh # Homebrew install + Brewfile ops
│ ├── mackup.sh # Mackup install + backup/restore │ ├── mackup.sh # Mackup install + backup/restore
│ ├── apps.sh # Detect unmanaged .app bundles (not brew/MAS)
│ └── macos.sh # macOS-specific actions (Rosetta, defaults, etc.) │ └── macos.sh # macOS-specific actions (Rosetta, defaults, etc.)
├── config/ ├── config/
│ ├── ssr.conf.example # Reference user config │ ├── ssr.conf.example # Reference user config
+7
View File
@@ -30,6 +30,13 @@ else
echo " last sync: never" echo " last sync: never"
fi fi
# Surface unmanaged-app count from the last sync, if a report exists.
if backup_dir="$(ssr::cloud::backup_dir 2>/dev/null)" \
&& [[ -f "$backup_dir/unmanaged-apps.md" ]]; then
n="$(grep -cE '^\| \[' "$backup_dir/unmanaged-apps.md" 2>/dev/null || echo 0)"
echo " unmanaged: $n app(s) (see $backup_dir/unmanaged-apps.md)"
fi
label="${SSR_LAUNCHD_LABEL:-de.surke.ssr.sync}" label="${SSR_LAUNCHD_LABEL:-de.surke.ssr.sync}"
service_target="gui/$(id -u)/${label}" service_target="gui/$(id -u)/${label}"
if launchctl print "$service_target" >/dev/null 2>&1 \ if launchctl print "$service_target" >/dev/null 2>&1 \
+20
View File
@@ -11,6 +11,8 @@ source "$SSR_ROOT/lib/cloud.sh"
source "$SSR_ROOT/lib/brew.sh" source "$SSR_ROOT/lib/brew.sh"
# shellcheck source=../lib/mackup.sh # shellcheck source=../lib/mackup.sh
source "$SSR_ROOT/lib/mackup.sh" source "$SSR_ROOT/lib/mackup.sh"
# shellcheck source=../lib/apps.sh
source "$SSR_ROOT/lib/apps.sh"
ssr::require_macos ssr::require_macos
ssr::load_config ssr::load_config
@@ -34,6 +36,24 @@ ssr::mackup::backup
ssr::brew::dump "$backup_dir" ssr::brew::dump "$backup_dir"
ssr::brew::cleanup ssr::brew::cleanup
# Surface .app bundles that aren't covered by brew cask or the App Store —
# they would otherwise be missed on `ssr restore` and need manual reinstall.
if [[ "${SSR_SCAN_UNMANAGED_APPS:-true}" == "true" ]]; then
ssr::log "Scanning for unmanaged .app bundles"
report_path="$(ssr::apps::write_report "$backup_dir")"
# Each row in the Markdown table starts with `| [`; count those.
count="$(grep -cE '^\| \[' "$report_path" 2>/dev/null || echo 0)"
if [[ "$count" -gt 0 ]]; then
ssr::warn "Found $count unmanaged app(s) — manual reinstall on restore:"
# Extract the linked app name from each table row for a quick console summary.
grep -E '^\| \[' "$report_path" \
| sed -E 's/^\| \[([^]]+)\].*/ - \1/'
ssr::log "Full report: $report_path"
else
ssr::ok "No unmanaged apps detected."
fi
fi
# Record sync time so `ssr status` can report freshness. # Record sync time so `ssr status` can report freshness.
state_dir="${SSR_STATE_DIR:-$HOME/.local/state/ssr}" state_dir="${SSR_STATE_DIR:-$HOME/.local/state/ssr}"
ssr::ensure_dir "$state_dir" ssr::ensure_dir "$state_dir"
+10
View File
@@ -22,6 +22,16 @@ SSR_MACKUP_SUBDIR=Mackup
SSR_BREW_UPGRADE=true SSR_BREW_UPGRADE=true
SSR_BREW_CLEANUP=true SSR_BREW_CLEANUP=true
# ---------------------------------------------------------------------------
# Unmanaged-app scan during sync
# ---------------------------------------------------------------------------
# Lists .app bundles installed manually (not via brew cask, not via MAS).
# Writes <backup_dir>/unmanaged-apps.txt for restore-time reference.
SSR_SCAN_UNMANAGED_APPS=true
# Colon-separated directories to scan (top level only).
# SSR_APP_DIRS=/Applications:$HOME/Applications
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# macOS restore behavior # macOS restore behavior
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+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
}