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:
@@ -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
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user