Last active
August 13, 2025 02:05
-
-
Save apfelchips/5336fd75a18bd9137266abd356f31c6f to your computer and use it in GitHub Desktop.
rename IPA files by parsing the contained info.plist
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
set -euo pipefail | |
IFS=$'\n\t' | |
# src: https://gist.github.com/apfelchips/5336fd75a18bd9137266abd356f31c6f | |
# required commands: xmllint unzip plutil | |
# plutil is provided by the gnustep-base-runtime package on debian / ubuntu | |
RENAME=1 | |
VERBOSE=0 | |
# realpath polyfill | |
abspath(){ | |
[ ${1:+x} ] || return 1 | |
p=$1 | |
until [ _"${p%/}" = _"$p" ] | |
do | |
p=${p%/} | |
done | |
[ -e "$p" ] && p=$1 | |
[ -d "$1" ] && p=$p/ | |
set 10 "$(pwd)" "${OLDPWD:-}" | |
PWD= | |
CDPATH="" cd -P "$2" && while [ "$1" -gt 0 ] | |
do | |
set "$1" "$2" "$3" "${p%/*}" | |
[ _"$p" = _"$4" ] || { | |
CDPATH="" cd -P "${4:-/}" || break | |
p=${p##*/} | |
} | |
[ ! -L "$p" ] && p=${PWD%/}${p:+/}$p && set "$@" "${p:-/}" && break | |
set $(($1-1)) "$2" "$3" "$p" | |
p=$(ls -dl "$p") || break | |
p=${p#*" $4 -> "} | |
done 2> /dev/null | |
cd "$2" && OLDPWD=$3 && [ ${5+x} ] && printf '%s\n' "$5" | |
} | |
analyze_macho(){ | |
# https://github.com/golang/go/blob/master/src/debug/macho/macho.go#L33 | |
# https://en.wikipedia.org/wiki/Mach-O#Multi-architecture_binaries | |
IPA_FILE_PATH="${1}" | |
INFO_FILE_IN_IPA_PATH="${2}" | |
BINARY=$(set +x; unzip -p "${IPA_FILE_PATH}" "${EXECUTABLE_FILE_IN_IPA_PATH}" | head -c 512 || if [[ $? -eq 141 ]]; then true; else exit $?; fi) | |
# Magic numbers for Mach-O headers | |
MH_MAGIC="feedface" # 32-bit | |
MH_CIGAM="cefaedfe" # 32-bit (reversed endian) | |
MH_MAGIC_64="feedfacf" # 64-bit | |
MH_CIGAM_64="cffaedfe" # 64-bit (reversed endian) | |
FAT_MAGIC="cafebabe" # Universal binary | |
FAT_CIGAM="bebafeca" # Universal binary (reversed endian) | |
FAT_MAGIC_64="cafebabf" # Universal binary (64-bit) | |
FAT_CIGAM_64="bfbafeca" # Universal binary (64-bit, reversed endian) | |
# LC_ENCRYPTION_INFO = 0x21, LC_ENCRYPTION_INFO_64 = 0x2C | |
LC_ENCRYPTION_INFO_HEX="21000000" | |
LC_ENCRYPTION_INFO_64_HEX="2c000000" | |
# Determine binary type | |
BINARY_MAGIC=$(xxd -l 4 -p - <<< $BINARY) | |
BINARY_TYPE="Unknown" | |
case "$BINARY_MAGIC" in | |
"$MH_MAGIC") | |
BINARY_TYPE="32-bit Mach-O" | |
ENDIAN="big" | |
;; | |
"$MH_CIGAM") | |
BINARY_TYPE="32-bit Mach-O (reversed endian)" | |
ENDIAN="little" | |
;; | |
"$MH_MAGIC_64") | |
BINARY_TYPE="64-bit Mach-O" | |
ENDIAN="big" | |
;; | |
"$MH_CIGAM_64") | |
BINARY_TYPE="64-bit Mach-O (reversed endian)" | |
ENDIAN="little" | |
;; | |
"$FAT_MAGIC" | "$FAT_CIGAM" | "$FAT_MAGIC_64" | "$FAT_CIGAM_64") | |
BINARY_TYPE="Universal (fat) binary" | |
;; | |
*) | |
echo "Unknown binary format: $BINARY_MAGIC" | |
exit 1 | |
;; | |
esac | |
echo "Binary type: $BINARY_TYPE" | |
} | |
while getopts "dv" opt; do | |
case ${opt} in | |
d) | |
RENAME=0 | |
;; | |
v) | |
VERBOSE=1 | |
;; | |
\?) | |
echo "Invalid option: -$OPTARG" >&2 | |
exit 1 | |
;; | |
esac | |
done | |
shift $((OPTIND -1)) | |
FAILED_ITEMS=() | |
INPUT_ITEMS="" | |
if [ -p /dev/stdin ]; then | |
INPUT_ITEMS="$(cat /dev/stdin)" | |
else | |
INPUT_ITEMS=$(IFS="$' \t\n'"; for FILE in "$@"; do echo "${FILE}"; done) | |
fi | |
for IPA_FILE in ${INPUT_ITEMS}; do | |
if [ ! -f "${IPA_FILE}" ] || [ -L "${IPA_FILE}" ]; then | |
FAILED_ITEMS+=("${IPA_FILE}") | |
continue | |
fi | |
IPA_FILE_PATH=$(abspath "${IPA_FILE}") | |
IPA_FILE_NAME=$(basename "${IPA_FILE_PATH}") | |
if ! file --mime-type -b "${IPA_FILE_PATH}" | grep -iq -e '^application/x-ios-app$' -e '^application/zip$'; then | |
echo "${IPA_FILE} is not a valid iOS App Store Package" | |
FAILED_ITEMS+=("${IPA_FILE_PATH}") | |
continue | |
fi | |
echo "File: ${IPA_FILE_PATH}" | |
INFO_FILE_IN_IPA_PATH=$(unzip -Z1 "${IPA_FILE_PATH}" | grep -i -E "^Payload/[^/]*\.app/Info\.plist$" || true) | |
if [ -z "${INFO_FILE_IN_IPA_PATH}" ]; then | |
FAILED_ITEMS+=("${IPA_FILE_PATH}") | |
continue | |
fi | |
INFO_FILE_CONTENT=$(unzip -p "${IPA_FILE_PATH}" "${INFO_FILE_IN_IPA_PATH}" | plutil -convert xml1 -o - -- -) | |
BUNDLE_ID=$(xmllint --xpath "/plist/dict/key[text()='CFBundleIdentifier']/following-sibling::string[1]/text()" - <<< $INFO_FILE_CONTENT 2>/dev/null) | |
BUNDLE_NAME=$(xmllint --xpath "/plist/dict/key[text()='CFBundleName']/following-sibling::string[1]/text()" - <<< $INFO_FILE_CONTENT 2>/dev/null || true) | |
EXECUTABLE_NAME=$(xmllint --xpath "/plist/dict/key[text()='CFBundleExecutable']/following-sibling::string[1]/text()" - <<< $INFO_FILE_CONTENT 2>/dev/null) | |
EXECUTABLE_FILE_IN_IPA_PATH=$(unzip -Z1 "${IPA_FILE_PATH}" | grep -i -E "^Payload/[^/]*\.app/${EXECUTABLE_NAME}\$") | |
DEVICE_FAMILY=() | |
DEVICE_FAMILY_COUNT=$(xmllint --xpath "count(/plist/dict/key[.='UIDeviceFamily']/following-sibling::array[1]/integer)" - <<< $INFO_FILE_CONTENT || echo 0) | |
counter=1 | |
while [ "$counter" -le "${DEVICE_FAMILY_COUNT}" ]; do | |
DEVICE_ID=$(xmllint --xpath "(/plist/dict/key[.='UIDeviceFamily']/following-sibling::array[1]/integer)[${counter}]/text()" - <<< $INFO_FILE_CONTENT) | |
case "${DEVICE_ID}" in | |
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/iPhoneOSKeys.html#//apple_ref/doc/uid/TP40009252-SW11 | |
1) DEVICE_FAMILY+=("iPhoneOS");; | |
2) DEVICE_FAMILY+=("iPadOS");; | |
3) DEVICE_FAMILY+=("tvOS");; | |
4) DEVICE_FAMILY+=("watchOS");; | |
5) DEVICE_FAMILY+=("HomePodOS");; | |
6) DEVICE_FAMILY+=("macOS");; | |
*) | |
esac | |
((counter++)) | |
done | |
DEVICE_FAMILY=$(IFS=+ ; echo "${DEVICE_FAMILY[*]:-'iOS'}") | |
LONG_VERSION_STRING=$(xmllint --xpath "/plist/dict/key[text()='CFBundleVersion']/following-sibling::string[1]/text()" - <<< $INFO_FILE_CONTENT 2>/dev/null || true) | |
SHORT_VERSION_STRING=$(xmllint --xpath "/plist/dict/key[text()='CFBundleShortVersionString']/following-sibling::string[1]/text()" - <<< $INFO_FILE_CONTENT 2>/dev/null || true) | |
VERSION_STRING=${SHORT_VERSION_STRING:-"${LONG_VERSION_STRING}"} | |
MIN_OS_VERSION=$(xmllint --xpath "/plist/dict/key[text()='MinimumOSVersion']/following-sibling::string[1]/text()" - <<< $INFO_FILE_CONTENT 2>/dev/null || true) | |
CLUTCH_VERSION=$( echo "${IPA_FILE}" | awk 'match(tolower($0), /clutch-\d+\.\d+\.\d+/) {print substr($0, RSTART, RLENGTH)}' | cut -d'-' -f2 || true) | |
CRC32=$(crc32 "${IPA_FILE}" | awk '{print $1}') | |
NEW_NAME=$(printf "${BUNDLE_ID}_v${VERSION_STRING:-'UNKNOWN'}_${BUNDLE_NAME:-'UNKNOWN'}_${DEVICE_FAMILY:-UNKNOWN}${MIN_OS_VERSION:-'0'}_${CRC32}.ipa" | tr '\n' '-' | tr -d '\r' | tr ' ' '-' | tr -dc '[:alnum:]()._@+-') | |
cat <<EOF | |
CFBundleName: ${BUNDLE_NAME:-'N/A'} | |
CFBundleIdentifier: ${BUNDLE_ID:-"N/A"} | |
CFBundleVersion: ${LONG_VERSION_STRING:-"N/A"} | |
CFBundleShortVersionString: ${SHORT_VERSION_STRING:-"N/A"} | |
UIDeviceFamily: ${DEVICE_FAMILY-"N/A"} | |
MinimumOSVersion: ${MIN_OS_VERSION:-"N/A"} | |
crc32: ${CRC32} | |
Clutch Version: ${CLUTCH_VERSION:-"N/A"} | |
EOF | |
if [ $VERBOSE -ge 1 ]; then | |
xmllint --format - <<< $INFO_FILE_CONTENT | |
fi | |
analyze_macho "${IPA_FILE_PATH}" "${EXECUTABLE_FILE_IN_IPA_PATH}" | |
if [ "${IPA_FILE_NAME}" != "${NEW_NAME}" ]; then | |
echo "New Name: ${IPA_FILE_NAME} --> ${NEW_NAME}" | |
if ((RENAME)); then | |
IPA_DIRNAME=$(dirname "${IPA_FILE_PATH}") | |
mv -n "${IPA_FILE_PATH}" "${IPA_DIRNAME}/${NEW_NAME}" | |
fi | |
else | |
echo "Keeping current name: ${IPA_FILE_NAME}" | |
fi | |
echo "---" | |
done | |
if [ "${#FAILED_ITEMS[@]}" -ne 0 ]; then | |
echo "FAILED ITEMS:" | |
for ERROR in "${FAILED_ITEMS[@]}"; do | |
echo "${ERROR}" | |
done | |
exit 1 | |
fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment