diff --git a/README.md b/README.md index cc1cef9..6f10804 100644 --- a/README.md +++ b/README.md @@ -70,11 +70,25 @@ Output structure in the cloud drive: ├── BACKUP/ │ └── - / │ ├── Brewfile -│ └── Brewfile.bak +│ ├── Brewfile.bak +│ └── unmanaged-apps.md # apps installed manually (not brew/MAS) └── 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 ` macOS download`. + ### Restore on a new machine ```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_BREW_UPGRADE` | `true` | Run `brew upgrade` during 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_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 | @@ -217,6 +233,7 @@ ssr update --dry-run # show what would change, don't pull │ ├── cloud.sh # Provider abstraction │ ├── brew.sh # Homebrew install + Brewfile ops │ ├── mackup.sh # Mackup install + backup/restore +│ ├── apps.sh # Detect unmanaged .app bundles (not brew/MAS) │ └── macos.sh # macOS-specific actions (Rosetta, defaults, etc.) ├── config/ │ ├── ssr.conf.example # Reference user config diff --git a/bin/ssr-status b/bin/ssr-status index 8e2520c..59acb6c 100755 --- a/bin/ssr-status +++ b/bin/ssr-status @@ -30,6 +30,13 @@ else echo " last sync: never" 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}" service_target="gui/$(id -u)/${label}" if launchctl print "$service_target" >/dev/null 2>&1 \ diff --git a/bin/ssr-sync b/bin/ssr-sync index 12f815f..e73afdf 100755 --- a/bin/ssr-sync +++ b/bin/ssr-sync @@ -11,6 +11,8 @@ source "$SSR_ROOT/lib/cloud.sh" source "$SSR_ROOT/lib/brew.sh" # shellcheck source=../lib/mackup.sh source "$SSR_ROOT/lib/mackup.sh" +# shellcheck source=../lib/apps.sh +source "$SSR_ROOT/lib/apps.sh" ssr::require_macos ssr::load_config @@ -34,6 +36,24 @@ ssr::mackup::backup ssr::brew::dump "$backup_dir" 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. state_dir="${SSR_STATE_DIR:-$HOME/.local/state/ssr}" ssr::ensure_dir "$state_dir" diff --git a/config/ssr.conf.example b/config/ssr.conf.example index 2b570c9..da4377e 100644 --- a/config/ssr.conf.example +++ b/config/ssr.conf.example @@ -22,6 +22,16 @@ SSR_MACKUP_SUBDIR=Mackup SSR_BREW_UPGRADE=true SSR_BREW_CLEANUP=true +# --------------------------------------------------------------------------- +# Unmanaged-app scan during sync +# --------------------------------------------------------------------------- +# Lists .app bundles installed manually (not via brew cask, not via MAS). +# Writes /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 # --------------------------------------------------------------------------- diff --git a/lib/apps.sh b/lib/apps.sh new file mode 100644 index 0000000..5472d50 --- /dev/null +++ b/lib/apps.sh @@ -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 ` 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 +}