#!/bin/bash
# Mole - Slim command
# Remove the Intel code from universal apps on Apple Silicon. Every app is
# slimmed on a clone, verified, and only then swapped in; the original goes
# to the Trash.
set -euo pipefail
# User state and installed tools must never run with inherited root privileges.
if [[ "$EUID" -eq 0 ]]; then
printf '%s\n' 'Run Mole without sudo; mo slim only changes apps you own.' >&2
exit 1
fi
# shellcheck disable=SC2154
# External variables set by menu_paginated.sh
declare MOLE_SELECTION_RESULT
export LC_ALL=C
export LANG=C
export MOLE_CURRENT_COMMAND="${MOLE_CURRENT_COMMAND:-slim}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../lib/core/common.sh"
source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh"
# The selection menu manages its own alternate screen.
cleanup() {
show_cursor
cleanup_temp_files
}
trap cleanup EXIT
trap 'trap - EXIT; cleanup; exit 130' INT TERM
readonly SLIM_EXIT_UNSUPPORTED=4
# Signature and Gatekeeper checks read every file of a large app.
readonly SLIM_VERIFY_TIMEOUT_SEC=180
# Assessing an original during the scan; a slower answer counts as unknown.
readonly SLIM_SCAN_GATEKEEPER_TIMEOUT_SEC=20
SLIM_WORK_DIR=""
# Scan results, one index per app.
SLIM_APP_PATHS=()
SLIM_APP_NAMES=()
SLIM_APP_BYTES=()
SLIM_APP_IDENTITIES=()
SLIM_APP_TEAMS=()
SLIM_APP_PLANS=()
total_slimmed=0
total_freed_bytes=0
total_failed=0
SLIM_DONE_NAMES=()
SLIM_FAILED_NAMES=()
SLIM_FAILED_REASONS=()
show_slim_help() {
echo "Usage: mo slim [OPTIONS]"
echo ""
echo "Remove the Intel code from universal apps on Apple Silicon."
echo "Each app is slimmed on a copy that must pass code signing and Gatekeeper"
echo "checks before it replaces the original, which moves to the Trash."
echo "App updates bring the Intel code back."
echo "Apps protected by ~/.config/mole/whitelist are skipped."
echo ""
echo "Options:"
echo " --dry-run, -n List apps and reclaimable space without changes"
echo " --debug Show why apps were skipped"
echo " -h, --help Show this help message"
}
slim_app_roots() {
if [[ -n "${MOLE_SLIM_TEST_APP_ROOTS:-}" ]] &&
[[ "${MOLE_TEST_MODE:-0}" == "1" || "${MOLE_TEST_NO_AUTH:-0}" == "1" ]]; then
local IFS=':'
local root
for root in $MOLE_SLIM_TEST_APP_ROOTS; do
printf '%s\n' "$root"
done
return 0
fi
printf '%s\n' "/Applications" "$HOME/Applications"
}
slim_host_is_apple_silicon() {
if [[ -n "${MOLE_SLIM_TEST_HOST_ARM64:-}" && "${MOLE_TEST_MODE:-0}" == "1" ]]; then
[[ "$MOLE_SLIM_TEST_HOST_ARM64" == "1" ]]
return
fi
# uname -m reports x86_64 inside a Rosetta shell; the sysctl does not.
# Absolute path: a PATH without /usr/sbin made every Mac read as Intel (#1804).
[[ "$(/usr/sbin/sysctl -n hw.optional.arm64 2> /dev/null || echo 0)" == "1" ]]
}
slim_plist_value() {
local plist="$1" key="$2"
[[ -f "$plist" ]] || return 1
plutil -extract "$key" raw -o - "$plist" 2> /dev/null
}
# Print the Team ID when the bundle is signed with a Developer ID certificate.
# App Store, Apple, ad-hoc and unsigned bundles fail.
slim_signing_team() {
local app="$1" info
info=$(run_with_timeout "$MOLE_TIMEOUT_MEDIUM_PROBE_SEC" codesign -dv --verbose=2 "$app" 2>&1 < /dev/null) || return 1
printf '%s\n' "$info" | grep -q '^Authority=Developer ID Application: ' || return 1
local team
team=$(printf '%s\n' "$info" | awk -F= '$1 == "TeamIdentifier" { print $2; exit }')
[[ "$team" =~ ^[A-Z0-9]{10}$ ]] || return 1
printf '%s\n' "$team"
}
slim_app_is_running() {
local app="${1%/}" comm
while IFS= read -r comm; do
[[ "$comm" == "$app/"* ]] && return 0
done < <(ps -axww -o comm= 2> /dev/null || true)
return 1
}
# "Open using Rosetta" in Finder's Get Info is stored per bundle id.
slim_app_prefers_rosetta() {
local bundle_id="$1"
local plist="$HOME/Library/Preferences/com.apple.LaunchServices/com.apple.LaunchServices.plist"
[[ -f "$plist" ]] || return 1
local xml
xml=$(plutil -extract LSArchitecturesForX86_64 xml1 -o - "$plist" 2> /dev/null) || return 1
printf '%s\n' "$xml" | awk -v want="$bundle_id" '
{ gsub(/^[ \t]+|[ \t]+$/, "") }
$0 == want { found = 1 }
END { exit found ? 0 : 1 }
'
}
# Print the reason an app is out of scope, or nothing when it may be slimmed.
# Running apps are listed and refused only when slimming starts.
slim_skip_reason() {
local app="$1" check_running="${2:-true}"
if is_path_whitelisted "$app"; then
echo "whitelisted"
return
fi
[[ -L "$app" ]] && {
echo "symlink"
return
}
[[ -d "$app/Contents" ]] || {
echo "not an app bundle"
return
}
[[ -d "$app/Contents/_MASReceipt" ]] && {
echo "App Store app"
return
}
local owner
owner=$("$STAT_BSD" -f %u "$app" 2> /dev/null || echo "")
[[ "$owner" == "$(id -u)" ]] || {
echo "not owned by you"
return
}
[[ -w "$(dirname "$app")" ]] || {
echo "folder not writable"
return
}
if [[ -n "$(find "$app" ! -user "$(id -un)" -print -quit 2> /dev/null)" ]]; then
echo "contains files owned by another user"
return
fi
local bundle_id
bundle_id=$(slim_plist_value "$app/Contents/Info.plist" CFBundleIdentifier || echo "")
[[ -n "$bundle_id" ]] || {
echo "no bundle identifier"
return
}
[[ "$bundle_id" == com.apple.* ]] && {
echo "Apple app"
return
}
if should_protect_from_uninstall "$bundle_id" ||
official_uninstaller_vendor "$bundle_id" "" "$app" > /dev/null; then
echo "protected app"
return
fi
slim_app_prefers_rosetta "$bundle_id" && {
echo "set to open using Rosetta"
return
}
if [[ "$check_running" == true ]] && slim_app_is_running "$app"; then
echo "still running"
return
fi
return 0
}
# Print "\t" for every files2 key in CodeResources: c when it is
# sealed by cdhash (nested code with its own signature), h otherwise. A key
# sealed by hash2 is a plain file: thinning one breaks the bundle seal.
# Parses the XML form; plutil -p is not parseable.
slim_sealed_keys() {
local code_resources="$1"
plutil -convert xml1 -o - "$code_resources" 2> /dev/null | awk '
function decode(s) {
gsub(/</, "<", s); gsub(/>/, ">", s)
gsub(/"/, "\"", s); gsub(/'/, "\047", s)
gsub(/&/, "\\&", s)
return s
}
{ line = $0; gsub(/^[ \t]+|[ \t]+$/, "", line) }
!in_files2 && line == "files2" { want_dict = 1; next }
want_dict {
want_dict = 0
if (line == "") { in_files2 = 1; depth = 1 }
next
}
!in_files2 { next }
line == "" { if (depth == 1) print "h\t" decode(key); next }
line == "" { depth++; if (depth == 2) { sealed = 0 }; next }
line == "" {
if (depth == 2) print (sealed ? "c" : "h") "\t" decode(key)
depth--
if (depth == 0) exit
next
}
depth == 1 && line ~ /^.*<\/key>$/ {
key = substr(line, 6, length(line) - 11)
next
}
depth == 2 && line == "cdhash" { sealed = 1 }
'
}
# Directory holding the _CodeSignature that seals this file: Contents/ for
# app-like bundles, Versions// for frameworks.
slim_sealing_root() {
local file="$1" app="${2%/}" dir
dir=$(dirname "$file")
while [[ "$dir" == "$app"/* || "$dir" == "$app" ]]; do
if [[ -f "$dir/_CodeSignature/CodeResources" ]]; then
printf '%s\n' "$dir"
return 0
fi
[[ "$dir" == "$app" ]] && break
dir=$(dirname "$dir")
done
return 1
}
# A bundle's main executable carries the bundle signature itself, so the
# seal never lists it.
slim_is_main_executable() {
local file="$1" root="$2" exe=""
if [[ "$(dirname "$file")" == "$root/MacOS" ]]; then
exe=$(slim_plist_value "$root/Info.plist" CFBundleExecutable || echo "")
elif [[ "$(dirname "$file")" == "$root" && "$root" == *.framework/Versions/* ]]; then
exe=$(slim_plist_value "$root/Resources/Info.plist" CFBundleExecutable || echo "")
fi
[[ -n "$exe" && "$(basename "$file")" == "$exe" ]]
}
# Fat headers are read with od, so slimming needs no developer tools: lipo
# comes with the Command Line Tools, a larger install than most apps free.
# Print " " per slice; a non-fat file fails.
slim_fat_slices() {
local file="$1" entry n i p cpu sub arch off size file_size
local -a b=()
read -r -a b <<< "$(od -An -v -tu1 -N 8 "$file" 2> /dev/null | tr '\n' ' ')"
[[ ${#b[@]} -eq 8 ]] || return 1
case "${b[0]} ${b[1]} ${b[2]} ${b[3]}" in
"202 254 186 190") entry=20 ;; # FAT_MAGIC
"202 254 186 191") entry=32 ;; # FAT_MAGIC_64
*) return 1 ;;
esac
n=$(((b[4] << 24) | (b[5] << 16) | (b[6] << 8) | b[7]))
((n >= 1 && n <= 16)) || return 1
read -r -a b <<< "$(od -An -v -tu1 -N $((8 + n * entry)) "$file" 2> /dev/null | tr '\n' ' ')"
[[ ${#b[@]} -eq $((8 + n * entry)) ]] || return 1
file_size=$("$STAT_BSD" -f %z "$file" 2> /dev/null) || return 1
for ((i = 0; i < n; i++)); do
p=$((8 + i * entry))
cpu=$(((b[p] << 24) | (b[p + 1] << 16) | (b[p + 2] << 8) | b[p + 3]))
sub=$(((b[p + 5] << 16) | (b[p + 6] << 8) | b[p + 7]))
if [[ $entry -eq 20 ]]; then
off=$(((b[p + 8] << 24) | (b[p + 9] << 16) | (b[p + 10] << 8) | b[p + 11]))
size=$(((b[p + 12] << 24) | (b[p + 13] << 16) | (b[p + 14] << 8) | b[p + 15]))
else
off=$(((b[p + 8] << 56) | (b[p + 9] << 48) | (b[p + 10] << 40) | (b[p + 11] << 32) | (\
b[p + 12] << 24) | (b[p + 13] << 16) | (b[p + 14] << 8) | b[p + 15]))
size=$(((b[p + 16] << 56) | (b[p + 17] << 48) | (b[p + 18] << 40) | (b[p + 19] << 32) | (\
b[p + 20] << 24) | (b[p + 21] << 16) | (b[p + 22] << 8) | b[p + 23]))
fi
((off > 0 && size > 0 && off + size <= file_size)) || return 1
case "$cpu:$sub" in
16777228:2) arch=arm64e ;; # CPU_TYPE_ARM64, CPU_SUBTYPE_ARM64E
16777228:*) arch=arm64 ;;
16777223:8) arch=x86_64h ;; # CPU_TYPE_X86_64, CPU_SUBTYPE_X86_64_H
16777223:*) arch=x86_64 ;;
7:*) arch=i386 ;;
*) arch=other ;;
esac
printf '%s %s %s\n' "$arch" "$off" "$size"
done
}
# Print " " when the file holds exactly one arm64
# slice plus Intel slices and nothing else; that slice is the whole slimmed
# file. Any arm64e slice leaves the file alone: macOS refuses third-party
# arm64e code unless the arm64e ABI is enabled, so such an app may be running
# its Intel slice today and would have nothing left that runs.
slim_arm_slice() {
local file="$1" slices arch off size
local arm_count=0 arm_off=0 arm_size=0 intel=0
slices=$(slim_fat_slices "$file") || return 1
while read -r arch off size; do
case "$arch" in
arm64)
arm_count=$((arm_count + 1))
arm_off=$off
arm_size=$size
;;
x86_64 | x86_64h | i386) intel=$((intel + size)) ;;
*) return 1 ;;
esac
done <<< "$slices"
[[ $arm_count -eq 1 && $intel -gt 0 ]] || return 1
printf '%s %s %s\n' "$arm_off" "$arm_size" "$intel"
}
# Bytes of Intel slices in a universal file that also runs natively.
slim_intel_bytes() {
local info
info=$(slim_arm_slice "$1") || return 1
printf '%s\n' "${info##* }"
}
# A thin 64-bit Mach-O for Apple Silicon: MH_MAGIC_64 then CPU_TYPE_ARM64,
# both little-endian.
slim_is_thin_arm() {
[[ "$(od -An -v -tu1 -N 8 "$1" 2> /dev/null | tr -s ' \n' ' ')" == " 207 250 237 254 12 0 0 1 " ]]
}
# Set keys_file to the cached slim_sealed_keys output for a sealing root;
# roots and root_keys belong to the calling slim_plan_app.
slim_seal_keys_file() {
local root="$1" i
for ((i = 0; i < ${#roots[@]}; i++)); do
if [[ "${roots[i]}" == "$root" ]]; then
keys_file="${root_keys[i]}"
return 0
fi
done
keys_file=$(create_temp_file) || return 1
slim_sealed_keys "$root/_CodeSignature/CodeResources" > "$keys_file" || true
roots+=("$root")
root_keys+=("$keys_file")
}
# Write "\t" for every file that can
# be thinned without breaking a seal; print the total.
slim_plan_app() {
local app="${1%/}" plan_file="$2"
local -a roots=() root_keys=()
local total=0 file
: > "$plan_file"
while IFS= read -r file; do
local root
root=$(slim_sealing_root "$file" "$app") || continue
local allowed=false keys_file
if slim_is_main_executable "$file" "$root"; then
allowed=true
else
slim_seal_keys_file "$root" || return 1
grep -Fxq -- "c"$'\t'"${file#"$root"/}" "$keys_file" && allowed=true
fi
# An enclosing bundle may seal the same file by plain hash: ScreenFlow
# signs its QuickLook plugin but its own seal lists the plugin's
# executable as a file, so thinning that executable broke the app.
local outer="$root"
while [[ "$allowed" == true ]] && outer=$(slim_sealing_root "$outer" "$app"); do
slim_seal_keys_file "$outer" || return 1
grep -Fxq -- "h"$'\t'"${file#"$outer"/}" "$keys_file" && allowed=false
done
[[ "$allowed" == true ]] || {
debug_log "slim: keeping $file (sealed as a plain file)"
continue
}
local bytes
bytes=$(slim_intel_bytes "$file") || continue
printf '%s\t%s\n' "${file#"$app"/}" "$bytes" >> "$plan_file"
total=$((total + bytes))
done < <(find "$app/Contents" -type f \
-not -path '*/Resources/*' -not -path '*/_CodeSignature/*' \
-print0 2> /dev/null | xargs -0 file -N -F $'\t' 2> /dev/null |
awk -F'\t' '$2 ~ /Mach-O universal/ { print $1 }')
printf '%s\n' "$total"
}
collect_slim_candidates() {
SLIM_APP_PATHS=()
SLIM_APP_NAMES=()
SLIM_APP_BYTES=()
SLIM_APP_IDENTITIES=()
SLIM_APP_TEAMS=()
SLIM_APP_PLANS=()
local -a apps=()
local root dir app
while IFS= read -r root; do
[[ -d "$root" ]] || continue
# One level of plain folders such as Utilities (#1811); never a bundle
# or a symlinked folder, and every app still passes the same checks.
for dir in "$root" "$root"/*; do
if [[ "$dir" != "$root" ]]; then
[[ "$dir" != *.app && -d "$dir" && ! -L "$dir" ]] || continue
fi
for app in "$dir"/*.app; do
[[ -e "$app" || -L "$app" ]] || continue
apps+=("$app")
done
done
done < <(slim_app_roots)
local -a unsorted=()
for app in "${apps[@]+"${apps[@]}"}"; do
local reason
reason=$(slim_skip_reason "$app" false)
if [[ -n "$reason" ]]; then
debug_log "slim: skipping $app ($reason)"
continue
fi
local team
if ! team=$(slim_signing_team "$app"); then
debug_log "slim: skipping $app (not signed with a Developer ID)"
continue
fi
local plan_file bytes
plan_file=$(create_temp_file) || return 1
bytes=$(slim_plan_app "$app" "$plan_file") || bytes=0
if [[ ! "$bytes" =~ ^[0-9]+$ || "$bytes" -eq 0 ]]; then
debug_log "slim: skipping $app (no Intel code)"
continue
fi
local identity
identity=$(mole_deletion_identity "$app") || continue
unsorted+=("$bytes"$'\t'"$app"$'\t'"$identity"$'\t'"$team"$'\t'"$plan_file")
done
[[ ${#unsorted[@]} -gt 0 ]] || return 1
# An original Gatekeeper already rejects (an unnotarized build) can never
# yield a copy that passes, so it is left out rather than offered and
# failed after a full copy. All candidates are assessed at once, about a
# second in total; only an explicit rejection (3) removes one.
local -a pids=() accepted=()
local entry i gk
for entry in "${unsorted[@]}"; do
app="${entry#*$'\t'}"
run_with_timeout "$SLIM_SCAN_GATEKEEPER_TIMEOUT_SEC" spctl --assess --type execute "${app%%$'\t'*}" > /dev/null 2>&1 < /dev/null &
pids+=("$!")
done
for ((i = 0; i < ${#unsorted[@]}; i++)); do
gk=0
wait "${pids[i]}" || gk=$?
if [[ $gk -eq 3 ]]; then
app="${unsorted[i]#*$'\t'}"
debug_log "slim: skipping ${app%%$'\t'*} (Gatekeeper rejects the original)"
continue
fi
accepted+=("${unsorted[i]}")
done
[[ ${#accepted[@]} -gt 0 ]] || return 1
while IFS=$'\t' read -r bytes app identity team plan_file; do
SLIM_APP_BYTES+=("$bytes")
SLIM_APP_PATHS+=("$app")
SLIM_APP_IDENTITIES+=("$identity")
SLIM_APP_TEAMS+=("$team")
SLIM_APP_PLANS+=("$plan_file")
local name="${app##*/}"
SLIM_APP_NAMES+=("${name%.app}")
done < <(printf '%s\n' "${accepted[@]}" | sort -t$'\t' -k1,1nr)
return 0
}
slim_name_column_width() {
local columns="${COLUMNS:-$(tput cols 2> /dev/null || echo 80)}"
[[ "$columns" =~ ^[0-9]+$ && "$columns" -gt 0 ]] || columns=80
local name width max_width=18 available=$((columns - 14))
for name in "${SLIM_APP_NAMES[@]}"; do
width=$(get_display_width "$(mole_terminal_safe_text "$name")")
[[ $width -le $max_width ]] || max_width=$width
done
# Match the app selector's bounded name column, with no date column.
# Reserve four cells for the menu prefix, one gap and nine for size.
[[ $max_width -le 60 ]] || max_width=60
[[ $max_width -le $available ]] || max_width=$available
[[ $max_width -ge 1 ]] || max_width=1
printf '%s\n' "$max_width"
}
slim_display_row() {
local name width
local name_width="$3"
name=$(truncate_by_display_width "$(mole_terminal_safe_text "$1")" "$name_width")
width=$(get_display_width "$name")
printf '%s%*s %9s' "$name" "$((name_width - width))" "" "$(bytes_to_human "$2")"
}
print_slim_list() {
local i total=0 name_width
name_width=$(slim_name_column_width)
for ((i = 0; i < ${#SLIM_APP_PATHS[@]}; i++)); do
printf ' %s\n' "$(slim_display_row "${SLIM_APP_NAMES[i]}" "${SLIM_APP_BYTES[i]}" "$name_width")"
total=$((total + SLIM_APP_BYTES[i]))
done
printf '\n%s apps, %s of Intel code\n' "${#SLIM_APP_PATHS[@]}" "$(bytes_to_human "$total")"
}
record_slim_failure() {
local app="$1" reason="$2"
local name="${app##*/}"
SLIM_FAILED_NAMES+=("${name%.app}")
SLIM_FAILED_REASONS+=("$reason")
total_failed=$((total_failed + 1))
log_operation "$MOLE_CURRENT_COMMAND" "SKIPPED" "$app" "$reason"
}
slim_thin_file() {
local file="$1" info off size _intel
info=$(slim_arm_slice "$file") || return 1
read -r off size _intel <<< "$info"
local out mode
mode=$("$STAT_BSD" -f %Lp "$file") || return 1
# Bundle contents can already include any fixed suffix, including a link.
# Create the output exclusively rather than opening a bundle-owned path.
out=$(mktemp "$file.mole-slim.XXXXXX") || return 1
# head stops reading once it has the slice, so when the slice is not the
# last one tail dies of SIGPIPE; the size and Mach-O checks below are what
# decide whether the copy is whole.
if ! { tail -c +"$((off + 1))" "$file" 2> /dev/null || true; } | head -c "$size" > "$out" 2> /dev/null; then
rm -f "$out" # SAFE: exclusively created slice output inside the staging copy
return 1
fi
if [[ "$("$STAT_BSD" -f %z "$out" 2> /dev/null)" != "$size" ]] || ! slim_is_thin_arm "$out"; then
rm -f "$out" # SAFE: partial slice written by this function inside the staging copy
return 1
fi
if ! chmod "$mode" "$out" || ! mv -f "$out" "$file"; then
rm -f "$out" # SAFE: exclusively created slice output that was not installed
return 1
fi
}
# Every check runs on the slimmed copy; any failure keeps the original.
slim_verify_copy() {
local copy="$1" team="$2" plan_file="$3"
local rel _bytes
while IFS=$'\t' read -r rel _bytes; do
slim_is_thin_arm "$copy/$rel" || {
echo "Apple Silicon code missing"
return 1
}
done < "$plan_file"
run_with_timeout "$SLIM_VERIFY_TIMEOUT_SEC" codesign --verify --deep --strict "$copy" > /dev/null 2>&1 < /dev/null || {
echo "code signature check failed"
return 1
}
local copy_team
copy_team=$(slim_signing_team "$copy") || copy_team=""
[[ "$copy_team" == "$team" ]] || {
echo "signing team changed"
return 1
}
run_with_timeout "$SLIM_VERIFY_TIMEOUT_SEC" spctl --assess --type execute "$copy" > /dev/null 2>&1 < /dev/null || {
echo "Gatekeeper check failed"
return 1
}
return 0
}
slim_discard_stage() {
local stage="$1"
[[ -n "$stage" && "$stage" == "$SLIM_WORK_DIR"/app.* ]] || return 0
rm -rf "$stage" # SAFE: mktemp staging folder under this run's work dir, holding only the slimmed copy
}
slim_one_app() {
local index="$1"
local app="${SLIM_APP_PATHS[index]}"
local planned_identity="${SLIM_APP_IDENTITIES[index]}"
local team="${SLIM_APP_TEAMS[index]}"
local plan_file="${SLIM_APP_PLANS[index]}"
local bytes="${SLIM_APP_BYTES[index]}"
local reason identity
if ! load_mole_whitelist "$HOME"; then
record_slim_failure "$app" "whitelist unavailable"
return 0
fi
reason=$(slim_skip_reason "$app")
if [[ -n "$reason" ]]; then
record_slim_failure "$app" "$reason"
return 0
fi
identity=$(mole_deletion_identity "$app") || identity=""
if [[ "$identity" != "$planned_identity" ]]; then
record_slim_failure "$app" "changed since scan"
return 0
fi
# A signature that is already broken cannot prove the copy is intact.
local verify_out has_detritus=false
if ! verify_out=$(run_with_timeout "$SLIM_VERIFY_TIMEOUT_SEC" codesign --verify --deep --strict "$app" 2>&1 < /dev/null); then
if [[ "$verify_out" != *"detritus not allowed"* ]]; then
record_slim_failure "$app" "code signature already invalid"
return 0
fi
has_detritus=true
fi
local stage
stage=$(mktemp -d "$SLIM_WORK_DIR/app.XXXXXX") || {
record_slim_failure "$app" "no staging folder"
return 0
}
local copy="$stage/${app##*/}"
if ! ditto --clone "$app" "$copy" 2> /dev/null; then
slim_discard_stage "$stage"
record_slim_failure "$app" "copy failed"
return 0
fi
# finder xattrs fail --strict but sit outside the seal: strip them on the
# copy only, then the untouched copy has to pass in place of the original.
# -s keeps xattr on a symlink itself, never on a target outside the copy.
if [[ "$has_detritus" == "true" ]]; then
/usr/bin/xattr -rsd com.apple.FinderInfo "$copy" 2> /dev/null || true
/usr/bin/xattr -rsd com.apple.ResourceFork "$copy" 2> /dev/null || true
if ! run_with_timeout "$SLIM_VERIFY_TIMEOUT_SEC" codesign --verify --deep --strict "$copy" > /dev/null 2>&1 < /dev/null; then
slim_discard_stage "$stage"
record_slim_failure "$app" "code signature already invalid"
return 0
fi
fi
local rel _bytes
while IFS=$'\t' read -r rel _bytes; do
if ! slim_thin_file "$copy/$rel"; then
slim_discard_stage "$stage"
record_slim_failure "$app" "could not remove Intel code from ${rel##*/}"
return 0
fi
done < "$plan_file"
if ! reason=$(slim_verify_copy "$copy" "$team" "$plan_file"); then
slim_discard_stage "$stage"
record_slim_failure "$app" "$reason"
return 0
fi
# Protection may change during the copy or signature checks. A failed
# reload is unknown, never permission to replace the original.
if ! load_mole_whitelist "$HOME"; then
slim_discard_stage "$stage"
record_slim_failure "$app" "whitelist unavailable"
return 0
fi
if is_path_whitelisted "$app"; then
slim_discard_stage "$stage"
record_slim_failure "$app" "whitelisted"
return 0
fi
# The app may have started while the copy was checked.
if slim_app_is_running "$app"; then
slim_discard_stage "$stage"
record_slim_failure "$app" "still running"
return 0
fi
# From here until the copy is in place an interrupt would leave neither
# app in /Applications: the cleanup trap deletes the work folder, copy
# included. Hold INT and TERM over the swap and act on them after it.
local swap_signal=""
trap 'swap_signal=INT' INT
trap 'swap_signal=TERM' TERM
local delete_rc=0
MOLE_DELETE_MODE=trash mole_delete "$app" false "$planned_identity" || delete_rc=$?
local swap_failed=""
if [[ $delete_rc -ne 0 || -e "$app" ]]; then
swap_failed="could not move the original to Trash"
elif ! mv "$copy" "$app" 2> /dev/null; then
swap_failed="original is in the Trash, drag it back to ${app%/*}"
fi
trap 'trap - EXIT; cleanup; exit 130' INT TERM
if [[ -n "$swap_failed" ]]; then
[[ "$swap_failed" == "could not move"* ]] && slim_discard_stage "$stage"
record_slim_failure "$app" "$swap_failed"
[[ -z "$swap_signal" ]] || kill -s "$swap_signal" $$
[[ "$swap_failed" == "could not move"* ]] && mole_rc_timeout_or_signal "$delete_rc" && return "$delete_rc"
return 0
fi
[[ -z "$swap_signal" ]] || kill -s "$swap_signal" $$
slim_discard_stage "$stage"
total_slimmed=$((total_slimmed + 1))
SLIM_DONE_NAMES+=("${SLIM_APP_NAMES[index]}")
total_freed_bytes=$((total_freed_bytes + bytes))
log_operation "$MOLE_CURRENT_COMMAND" "REBUILT" "$app" "removed Intel code, $(bytes_to_human "$bytes")"
return 0
}
select_slim_apps() {
local -a rows=() sizes_kb=() names=()
local i name_width
name_width=$(slim_name_column_width)
for ((i = 0; i < ${#SLIM_APP_PATHS[@]}; i++)); do
rows+=("$(slim_display_row "${SLIM_APP_NAMES[i]}" "${SLIM_APP_BYTES[i]}" "$name_width")")
sizes_kb+=("$((SLIM_APP_BYTES[i] / 1024))")
names+=("$(mole_terminal_safe_text "${SLIM_APP_NAMES[i]}")")
done
MOLE_MENU_META_SIZEKB=$(
IFS=','
printf '%s' "${sizes_kb[*]}"
)
export MOLE_MENU_META_SIZEKB
MOLE_MENU_FILTER_NAMES=$(printf '%s\n' "${names[@]}")
export MOLE_MENU_FILTER_NAMES
export MOLE_MENU_SORT_MODE=size
export MOLE_MENU_SORT_REVERSE=false
MOLE_SELECTION_RESULT=""
local rc=0
paginated_multi_select "Select apps to slim" "${rows[@]}" || rc=$?
unset MOLE_MENU_META_SIZEKB MOLE_MENU_FILTER_NAMES
[[ $rc -eq 0 && -n "$MOLE_SELECTION_RESULT" ]]
}
confirm_slim_selection() {
local -a indices=("$@")
local idx total=0 running=false tag
echo -e "\n${PURPLE_BOLD}Apps to slim:${NC}"
for idx in "${indices[@]}"; do
tag=""
if slim_app_is_running "${SLIM_APP_PATHS[idx]}"; then
tag=" ${YELLOW}[Running]${NC}"
running=true
fi
printf '%b%s%b\n' "${BLUE}${ICON_CONFIRM}${NC} " "$(mole_terminal_safe_text "${SLIM_APP_NAMES[idx]}")" \
" ${GRAY}, $(bytes_to_human "${SLIM_APP_BYTES[idx]}")${NC}${tag}"
total=$((total + SLIM_APP_BYTES[idx]))
done
echo ""
echo -e "${GRAY}Originals move to the Trash. App updates bring the Intel code back.${NC}"
[[ "$running" == false ]] || echo -e "${GRAY}Running apps are skipped, quit them before you confirm.${NC}"
local app_text="apps"
[[ ${#indices[@]} -gt 1 ]] || app_text="app"
echo -ne "${PURPLE}${ICON_ARROW}${NC} Slim ${#indices[@]} ${app_text}, $(bytes_to_human "$total") ${GREEN}Enter${NC} confirm, ${GRAY}ESC${NC} cancel: "
local confirm
if ! IFS= read -r -s -n1 confirm; then
printf '\n'
return 1
fi
printf '\n'
case "$confirm" in
"" | $'\n' | $'\r') return 0 ;;
esac
printf '\n'
return 1
}
# Mirrors the uninstall summary: slimmed apps three per line, then the apps
# left unchanged grouped by reason so six running apps read as one line.
show_slim_summary() {
local -a details=()
local i name
if [[ $total_slimmed -gt 0 ]]; then
local app_text="apps"
[[ $total_slimmed -gt 1 ]] || app_text="app"
local line="Slimmed ${total_slimmed} ${app_text}, freed ${GREEN}$(bytes_to_human "$total_freed_bytes")${NC}: "
for ((i = 0; i < ${#SLIM_DONE_NAMES[@]}; i++)); do
if ((i > 0 && i % 3 == 0)); then
details+=("$line")
line=""
elif ((i > 0)); then
line+=", "
fi
name=$(mole_terminal_safe_text "${SLIM_DONE_NAMES[i]}")
line+="${GREEN}${name//\\/\\\\}${NC}"
done
details+=("$line")
fi
local -a reasons=()
local reason seen names label
for reason in "${SLIM_FAILED_REASONS[@]+"${SLIM_FAILED_REASONS[@]}"}"; do
for seen in "${reasons[@]+"${reasons[@]}"}"; do
[[ "$seen" != "$reason" ]] || continue 2
done
reasons+=("$reason")
done
for reason in "${reasons[@]+"${reasons[@]}"}"; do
names=""
for ((i = 0; i < ${#SLIM_FAILED_NAMES[@]}; i++)); do
[[ "${SLIM_FAILED_REASONS[i]}" == "$reason" ]] || continue
[[ -z "$names" ]] || names+=", "
names+="${SLIM_FAILED_NAMES[i]}"
done
names=$(mole_terminal_safe_text "$names")
label=$(mole_terminal_safe_text "$reason")
label="$(printf '%s' "${label:0:1}" | /usr/bin/tr '[:lower:]' '[:upper:]')${label:1}"
details+=("${ICON_LIST} ${label//\\/\\\\}: ${YELLOW}${names//\\/\\\\}${NC}")
if [[ "$reason" == "still running" ]]; then
local them="them"
[[ "$names" == *", "* ]] || them="it"
details+=("${GRAY}${ICON_REVIEW} Quit ${them}, then run mo slim again${NC}")
fi
done
[[ ${#details[@]} -gt 0 ]] || details+=("No apps were slimmed")
local title="Slim complete"
[[ $total_failed -eq 0 ]] || title="Slim incomplete"
print_summary_block "$title" "${details[@]}"
printf '\n'
}
slim_preflight() {
if ! slim_host_is_apple_silicon; then
printf 'mo slim needs an Apple Silicon Mac; on an Intel Mac the Intel code is the code that runs.\n' >&2
return "$SLIM_EXIT_UNSUPPORTED"
fi
load_mole_whitelist "$HOME" || return 1
SLIM_WORK_DIR=$(create_temp_dir) || return 1
return 0
}
main() {
local arg
for arg in "$@"; do
case "$arg" in
"--help" | "-h")
show_slim_help
exit 0
;;
"--debug") export MO_DEBUG=1 ;;
"--dry-run" | "-n") export MOLE_DRY_RUN=1 ;;
*)
echo "Unknown option: $arg" >&2
echo "Use 'mo slim --help' for supported options." >&2
exit 1
;;
esac
done
local rc=0
slim_preflight || rc=$?
[[ $rc -eq 0 ]] || return 1
if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then
echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No apps will be changed"
printf '\n'
fi
[[ -t 1 ]] && start_inline_spinner "Scanning apps..."
local collect_rc=0
collect_slim_candidates || collect_rc=$?
[[ -t 1 ]] && stop_inline_spinner
if [[ $collect_rc -ne 0 ]]; then
echo -e "${GREEN}${ICON_SUCCESS}${NC} No apps with Intel code to remove"
return 0
fi
# Without a terminal there is nobody to choose: list and stop.
if [[ "${MOLE_DRY_RUN:-0}" == "1" || ! -t 0 || ! -t 1 ]]; then
print_slim_list
if [[ "${MOLE_DRY_RUN:-0}" != "1" ]]; then
printf 'Run mo slim in a terminal to choose apps.\n'
fi
return 0
fi
hide_cursor
if ! select_slim_apps; then
show_cursor
printf '\n'
return 0
fi
show_cursor
local -a indices=()
IFS=',' read -ra indices <<< "$MOLE_SELECTION_RESULT"
confirm_slim_selection "${indices[@]}" || return 0
log_operation_session_start "$MOLE_CURRENT_COMMAND"
local idx slim_rc=0 current=0 before failed_before name prefix=""
for idx in "${indices[@]}"; do
[[ "$idx" =~ ^[0-9]+$ && $idx -lt ${#SLIM_APP_PATHS[@]} ]] || continue
current=$((current + 1))
name=$(mole_terminal_safe_text "${SLIM_APP_NAMES[idx]}")
# Like uninstall, number each app only when several are selected.
[[ ${#indices[@]} -le 1 ]] || prefix="[$current/${#indices[@]}] "
before=$total_slimmed
failed_before=${#SLIM_FAILED_REASONS[@]}
[[ -t 1 ]] && start_inline_spinner "${prefix}Slimming ${name}..."
slim_one_app "$idx" || slim_rc=$?
[[ -t 1 ]] && stop_inline_spinner
if [[ -t 1 && -n "$prefix" ]]; then
if [[ $total_slimmed -gt $before ]]; then
printf '%b%s%b\n' "${GREEN}${ICON_SUCCESS}${NC} ${prefix}" "$name" " ${GRAY}, $(bytes_to_human "${SLIM_APP_BYTES[idx]}")${NC}"
elif [[ ${#SLIM_FAILED_REASONS[@]} -gt $failed_before ]]; then
printf '%b%s%b%s%b\n' "${YELLOW}${ICON_WARNING}${NC} ${prefix}" "$name" " ${GRAY}, " \
"$(mole_terminal_safe_text "${SLIM_FAILED_REASONS[failed_before]}")" "$NC"
fi
fi
[[ $slim_rc -eq 0 ]] || break
done
log_operation_session_end "$MOLE_CURRENT_COMMAND" "$total_slimmed" "$((total_freed_bytes / 1024))"
show_slim_summary
[[ $slim_rc -eq 0 ]] || return "$slim_rc"
[[ $total_failed -eq 0 ]] || return 1
return 0
}
if [[ "${MOLE_TEST_MODE:-0}" != "1" ]]; then
main "$@"
fi