diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8bf26cd --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: test + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - name: Run checks + run: | + bash -n ipagrab install.sh tests/test.sh + bash tests/test.sh + git diff --check diff --git a/LAUNCH.md b/LAUNCH.md new file mode 100644 index 0000000..4f95e4d --- /dev/null +++ b/LAUNCH.md @@ -0,0 +1,32 @@ +# Launch material + +These are drafts only. Review them against the tagged commit before publishing. + +## GitHub description + +Save complete, validated IPAs from Apple Configurator's temporary cache on macOS. + +## Suggested topics + +`apple-configurator`, `ipa`, `ios`, `macos`, `app-archiving`, `shell-script` + +## Proposed v0.1.0 release notes + +`ipagrab` saves the IPA that Apple Configurator downloads to its temporary Mac cache before Configurator removes it. + +### Included + +- Watches for a new or changed IPA instead of copying an unchanged cache entry. +- Validates the ZIP archive before and after copying without letting an incomplete candidate block later downloads. +- Saves to the Desktop by default and adds a numeric suffix instead of overwriting a collision. +- Provides an interactive terminal watcher, concise `--help`, and clear preflight errors. +- Installs through a guarded symlink and removes only a link to the same checkout. +- Includes framework-free shell tests and a macOS GitHub Actions workflow. + +### Requirements and limits + +- Requires macOS, Apple Configurator, and an unlocked, trusted iPhone or iPad connected by USB. +- Configurator must be signed in with the Apple ID that owns the app and must offer the app for download. +- Saved IPAs remain FairPlay-encrypted; `ipagrab` does not decrypt, resign, or remove DRM. +- `ipagrab` does not download from Apple or read account credentials. It only copies Configurator's local cache. +- Automated tests cover archive validation, stale files, collisions, preflight errors, and installer safety with temporary fixtures. Real Apple Configurator and device testing has not been performed as part of this release preparation. diff --git a/README.md b/README.md index 4be8fe5..e382aab 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,113 @@ # ipagrab -`ipagrab` is a tiny tool to quickly download an IPA of an app you own from the App Store on macOS. Run it locally and privately, with no auth, no credentials, no tokens, no API calls. +**Save the IPA Apple Configurator downloads—before it deletes it.** ---- +Requires **macOS**, **Apple Configurator**, an **iPhone or iPad connected by USB**, and an **Apple ID that owns the app**. The saved IPA remains **FairPlay-encrypted**. -## ipatool or iMazing not working? +`ipagrab` does not download apps, call Apple services, bypass ownership, or read credentials. Apple Configurator performs the download; this shell script watches its local temporary cache and copies the completed IPA to your Mac. -You're not alone. Many users are running into these lately: - -**ipatool** -- `download` fails with `something went wrong` or `invalid response` -- Auth returns `ERR error="something went wrong" success=false` -- 2FA code never arrives, or fails after entry (`MZFinance.BadLogin.Configurator_message`) -- `list-versions` returns `FailureType: 5002` or "An unknown error has occurred" -- HTTP 200 but `Items: []` or `Items: null` — empty response -- Downloads stall at `0%` then die with `unknown error` -- HTTP 429 rate limiting (`mzauth|global|all`) -- Apple changed the login endpoint — ipatool auth is broken for many accounts - -**iMazing** -- The IPA download button is greyed out and can't be clicked - -These are Apple-side restrictions that third-party tools can't easily work around. `ipagrab` lets Apple Configurator (Apple's own app) handle the download, then instantly saves the `.ipa` to your Desktop. No credentials exposed to any third-party tool, no broken API calls. +## Requirements ---- +- macOS +- [Apple Configurator](https://apps.apple.com/app/apple-configurator/id1037126344) +- An unlocked, trusted iPhone or iPad connected by USB +- Apple Configurator signed in with the Apple ID that owns the app +- Apple Configurator opened at least once, plus a writable destination folder ## Install -**Step 1 — Open Terminal.** -Press `Command + Space`, type `Terminal`, press Enter. +```sh +git clone https://github.com/NiuLevel/ipagrab.git +cd ipagrab +./install.sh +``` + +The installer creates an `ipagrab` symlink in a writable command directory. It will not replace an unrelated file or symlink. If its fallback directory is not on `PATH`, it prints the exact line to add. -**Step 2 — Paste this and press Enter:** +Run without installing: ```sh -git clone https://github.com/NiuLevel/ipagrab.git && cd ipagrab && ./install.sh +./ipagrab ``` -**Step 3 — Open a new Terminal window.** You can now type `ipagrab` to launch. +Show command help without opening the interactive interface: -> Don't want to install? Just run `./ipagrab` from the folder. - -To uninstall: `cd ipagrab && ./install.sh uninstall` +```sh +ipagrab --help +``` ---- +Uninstall from the same checkout: -## Requirements +```sh +./install.sh uninstall +``` -- A **Mac** -- **Apple Configurator** — free on the Mac App Store: - -- An **iPhone or iPad** connected via **USB** (Configurator needs a device to trigger the download) -- Signed into Apple Configurator with the Apple ID that owns the app - (*Configurator menu bar → Account → Sign In…*) +Uninstall removes only a link that points to this checkout. ---- +## Use -## How to use it +1. Open Apple Configurator and sign in. +2. Connect the device by USB, unlock it, and trust the Mac. +3. Run `ipagrab`. Confirm the displayed cache and destination, choose **GO!**, and leave the watcher running. +4. In Configurator, select the device, then choose **Add → Apps…**, find the app, and choose **Add**. +5. Wait for `GRABBED`. The validated IPA is now in the destination folder, and `ipagrab` returns to its menu. +6. Choose **EXIT**. To stop before a grab finishes, press `S`, `Q`, or Escape. -1. Open **Apple Configurator** and sign in. -2. Connect your iPhone/iPad via USB → unlock it → tap **Trust This Computer**. -3. In Terminal, start the watcher **before** you download: - ```sh - ipagrab - ``` - Select **GO!** with arrow keys and press Enter. -4. In Apple Configurator: select your device → **Add → Apps…** → find the app → **Add**. -5. When you see: - ``` - ▓▒░ ✔ GRABBED ░▒▓ - 153045_AppName.ipa (84231234 bytes) - 📂 Reminder: your .ipa is on the Desktop - ``` - the `.ipa` is on your Desktop. -6. Press **`S`** or pick **EXIT** from the menu to stop. +Start the watcher before choosing **Add**. Files already in the cache when watching starts are ignored unless Configurator changes them afterward. -**Tip:** If the app is already on the device, Configurator will show a *"replace existing app?"* prompt. Leave it open — the `.ipa` stays in the cache the whole time, so `ipagrab` has plenty of time to copy it. Cancel the prompt once you see `✔ GRABBED`. +Saved files use `HHMMSS_.ipa`. If that name exists, `ipagrab` adds `_1`, `_2`, and so on instead of overwriting it. ---- +The interface labels each state as `READY`, `WATCHING`, `VALIDATING`, `GRABBED`, `STOPPED`, or `ERROR`. A successful result shows the filename, readable and exact size, destination, and elapsed time. -## What it actually does +## How it works -`ipagrab` is a ~130-line bash script. It does **not** download anything, talk to Apple, or touch your credentials. It watches one folder on your Mac — Apple Configurator's temp download cache — and the moment a `.ipa` appears, it copies it to your Desktop before Configurator deletes it. +`ipagrab` watches this Configurator cache: -> **Whatever Apple Configurator downloads, `ipagrab` saves.** +```text +~/Library/Group Containers/K36BKF7T3D.group.com.apple.configurator/Library/Caches/Assets/TemporaryItems/MobileApps +``` -Cache folder watched: -`~/Library/Group Containers/K36BKF7T3D.group.com.apple.configurator/Library/Caches/Assets/TemporaryItems/MobileApps` +When watching starts, it records the IPAs already present. For each new or changed IPA, it verifies that the file is a complete, readable ZIP archive. Incomplete candidates are reconsidered when they change, without blocking other downloads. A valid candidate is copied through a temporary file, validated again, and published without overwriting an existing file. ---- +Unlike tools that authenticate with App Store services themselves, `ipagrab` has no network or account integration. That keeps its role small, but also means it cannot work without Configurator, a connected device, and an eligible Apple account. ## Limitations -- **You can only get apps your Apple ID owns.** If Configurator won't download it, there's nothing to grab. -- **Downloading on the phone itself does NOT work.** The download must be driven by Apple Configurator on the Mac. -- **The `.ipa` is FairPlay-encrypted.** Fine for archiving and sideloading back onto your own devices. Decryption requires a separate process on a jailbroken device. -- **Start `ipagrab` before clicking Add** — it only catches files that appear while it's running. - ---- +- Configurator must offer and download the app. `ipagrab` cannot obtain an app the signed-in Apple ID does not own. +- The download must be initiated in Configurator with a USB-connected device; downloading on the device does not enter this Mac cache. +- Saved IPAs remain FairPlay-encrypted. `ipagrab` does not decrypt, resign, or remove DRM. +- If Configurator has not created its download cache yet, `ipagrab` waits for it while you start the download. +- Only a new or changed IPA is grabbed; unchanged cache files are deliberately ignored. +- The interactive watcher requires a Terminal. `--help` remains available in non-interactive environments. +- Apple can change Configurator or its cache layout, which may require this project to change. ## Troubleshooting -- **Nothing grabbed:** make sure the watcher was on **GO!** *before* you clicked Add, and that Configurator actually started downloading (you'll see a progress bar in Configurator). Retry. -- **Watching forever:** the cache folder is only created after Configurator has downloaded at least one app. The path is printed at startup — check that it exists. -- **App missing from the Add list:** use the search box. Delisted apps you own may be hidden — search by exact name. - ---- +- **Waiting for Configurator to create its cache:** leave `ipagrab` watching and start the app download in Configurator. +- **`Configurator cache does not exist`:** open Configurator once. If using `IPAGRAB_CACHE`, make sure that custom directory already exists. +- **Nothing is grabbed:** confirm the watcher says `WATCHING` before choosing **Add**, and confirm Configurator is actually downloading the app. +- **Destination error:** make sure the destination exists and is writable, or set `IPAGRAB_DEST` to another existing folder. +- **Installer refuses an existing path:** an unrelated `ipagrab` command is already there. Leave it untouched and run this checkout with `./ipagrab`, or resolve the conflict yourself. +- **App is missing in Configurator:** `ipagrab` cannot make Configurator offer an unavailable or ineligible app. -## Configuration (optional) +## Configuration -Override the cache path or destination with environment variables: +The defaults are Configurator's cache folder and `~/Desktop`. Override either for testing or a different destination: ```sh -IPAGRAB_CACHE=/path/to/cache IPAGRAB_DEST=/path/to/dest ipagrab +IPAGRAB_CACHE=/path/to/cache IPAGRAB_DEST=/path/to/destination ipagrab ``` -Defaults: Configurator's cache folder and `~/Desktop`. +Custom cache and destination paths must be absolute, and those directories must already exist. The default Configurator cache may appear after watching starts. The cache must be readable and the destination writable. ---- +Set `NO_COLOR=1` to disable optional ANSI colors: -## ⚠️ Responsible use & disclaimer +```sh +NO_COLOR=1 ipagrab +``` -`ipagrab` is a personal backup/archive tool only. By using it you agree to: +## Responsible use -- Only grab apps you legitimately own with your own Apple ID. -- Not distribute the `.ipa` files it saves. -- Not decrypt them or strip their DRM. +Use `ipagrab` only to archive apps you legitimately own. Do not distribute saved IPAs or use this project to bypass licensing, ownership, or DRM controls. You are responsible for complying with applicable laws and Apple's terms. -This tool is provided **"as is", without warranty of any kind** (see [LICENSE](LICENSE)). You are solely responsible for how you use it and for complying with all applicable laws and Apple's terms of service. The author accepts no liability for any misuse, damages, or legal consequences. +This project is provided as-is under the [MIT License](LICENSE). diff --git a/install.sh b/install.sh index 7ef3683..837f15b 100755 --- a/install.sh +++ b/install.sh @@ -7,9 +7,11 @@ set -e DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SRC="$DIR/ipagrab" NAME="ipagrab" +TARGET="" # Pick a target bin dir: prefer an existing, writable one already on PATH. on_path() { case ":$PATH:" in *":$1:"*) return 0;; *) return 1;; esac; } +points_to_source() { [ -L "$1" ] && [ "$(readlink "$1")" = "$SRC" ]; } pick_bindir() { local d for d in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/bin"; do @@ -19,12 +21,20 @@ pick_bindir() { mkdir -p "$HOME/.local/bin"; echo "$HOME/.local/bin" } -if [ "$1" = "uninstall" ]; then +[ "$#" -le 1 ] || { echo "error: usage: ./install.sh [uninstall]" >&2; exit 2; } +case "${1:-}" in + ''|uninstall) ;; + *) echo "error: usage: ./install.sh [uninstall]" >&2; exit 2;; +esac + +if [ "${1:-}" = "uninstall" ]; then removed=0 for d in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/bin"; do - if [ -L "$d/$NAME" ]; then rm -f "$d/$NAME" && echo "Removed $d/$NAME" && removed=1; fi + on_path "$d" || [ "$d" = "$HOME/.local/bin" ] || continue + TARGET="$d/$NAME" + if points_to_source "$TARGET"; then rm -f "$TARGET" && echo "Removed $TARGET" && removed=1; fi done - [ "$removed" = 0 ] && echo "No $NAME symlink found." + [ "$removed" = 0 ] && echo "No $NAME link for this checkout found." exit 0 fi @@ -32,9 +42,19 @@ fi chmod +x "$SRC" BIN="$(pick_bindir)" -ln -sf "$SRC" "$BIN/$NAME" +TARGET="$BIN/$NAME" +if [ -e "$TARGET" ] || [ -L "$TARGET" ]; then + if points_to_source "$TARGET"; then + echo "Already installed: $TARGET -> $SRC" + else + echo "error: refusing to overwrite existing path: $TARGET" >&2 + exit 1 + fi +else + ln -s "$SRC" "$TARGET" + echo "Installed: $TARGET -> $SRC" +fi -echo "Installed: $BIN/$NAME -> $SRC" if on_path "$BIN"; then echo "Done. Open a new terminal (or run \`hash -r\`) and type: $NAME" else diff --git a/ipagrab b/ipagrab index bd4bc8f..59d1b6d 100755 --- a/ipagrab +++ b/ipagrab @@ -2,21 +2,179 @@ # ipagrab — watch Apple Configurator's cache, grab the first complete .ipa to ~/Desktop. # Menu (arrows + Enter): GO! / EXIT. Override paths via IPAGRAB_CACHE / IPAGRAB_DEST. -CACHE="${IPAGRAB_CACHE:-$HOME/Library/Group Containers/K36BKF7T3D.group.com.apple.configurator/Library/Caches/Assets/TemporaryItems/MobileApps}" +CONFIGURATOR_HOME="$HOME/Library/Group Containers/K36BKF7T3D.group.com.apple.configurator" +DEFAULT_CACHE="$CONFIGURATOR_HOME/Library/Caches/Assets/TemporaryItems/MobileApps" +CACHE="${IPAGRAB_CACHE:-$DEFAULT_CACHE}" DEST="${IPAGRAB_DEST:-$HOME/Desktop}" -STABLE_POLLS=3 # size must hold steady this many 0.2s polls -MIN_BYTES=$((1024 * 1024)) # ignore files under 1 MB +USE_COLOR=0; RESET=""; BOLD=""; CYAN="" +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + USE_COLOR=1; RESET=$'\033[0m'; BOLD=$'\033[1m'; CYAN=$'\033[38;5;51m' +fi size_of() { stat -f%z "$1" 2>/dev/null || echo 0; } +fingerprint_of() { stat -f '%i:%m:%z' "$1" 2>/dev/null; } +archive_is_readable() { unzip -tqq "$1" >/dev/null 2>&1; } +state_line() { printf ' %s[%s]%s %s\n' "$BOLD$CYAN" "$1" "$RESET" "$2"; } +error() { printf '%s[ERROR]%s %s\n' "$BOLD" "$RESET" "$*" >&2; } + +human_size() { + local bytes=$1 unit suffix tenths + if [ "$bytes" -ge 1073741824 ]; then unit=1073741824; suffix=GiB + elif [ "$bytes" -ge 1048576 ]; then unit=1048576; suffix=MiB + elif [ "$bytes" -ge 1024 ]; then unit=1024; suffix=KiB + else printf '%s B' "$bytes"; return + fi + tenths=$(( (bytes * 10 + unit / 2) / unit )) + printf '%d.%d %s' "$((tenths / 10))" "$((tenths % 10))" "$suffix" +} + +show_help() { + printf '%s\n' \ + 'Usage: ipagrab [--help]' \ + '' \ + "Watch Apple Configurator's cache and save the next complete IPA to ~/Desktop." \ + '' \ + 'Options:' \ + ' -h, --help Show this help and exit.' \ + '' \ + 'Environment:' \ + ' IPAGRAB_CACHE Override the Configurator cache folder.' \ + ' IPAGRAB_DEST Override the destination folder.' \ + ' NO_COLOR Disable optional ANSI colors.' +} + +preflight() { + local os required + command -v uname >/dev/null 2>&1 || { error "required command not found: uname. Next: restore the standard macOS tools, then retry."; return 1; } + os=$(uname -s 2>/dev/null) || { error "could not identify the operating system. Next: run ipagrab in macOS Terminal."; return 1; } + [ "$os" = "Darwin" ] || { error "macOS is required; detected: $os. Next: run ipagrab on a Mac."; return 1; } + for required in stat unzip find mktemp cp rm basename date ln sleep dd stty tput; do + command -v "$required" >/dev/null 2>&1 || { error "required command not found: $required. Next: restore the standard macOS tools, then retry."; return 1; } + done + [ -t 0 ] && [ -t 1 ] || { error "an interactive terminal is required. Next: run ipagrab in macOS Terminal, or use --help."; return 1; } + validate_paths +} + +validate_paths() { + command -v unzip >/dev/null 2>&1 || { error "unzip is required to validate IPA archives. Next: restore the standard macOS tools, then retry."; return 1; } + case "$CACHE" in /*) ;; *) error "cache path must be absolute: $CACHE. Next: set IPAGRAB_CACHE to a path beginning with /."; return 1;; esac + case "$DEST" in /*) ;; *) error "destination path must be absolute: $DEST. Next: set IPAGRAB_DEST to a path beginning with /."; return 1;; esac + if [ -e "$CACHE" ]; then + [ -d "$CACHE" ] || { error "Configurator cache path is not a directory: $CACHE. Next: fix IPAGRAB_CACHE, then retry."; return 1; } + [ -r "$CACHE" ] && [ -x "$CACHE" ] || { error "Configurator cache is not readable: $CACHE. Next: check its permissions, then retry."; return 1; } + elif [ "$CACHE" != "$DEFAULT_CACHE" ] || [ ! -d "$CONFIGURATOR_HOME" ]; then + error "Configurator cache does not exist: $CACHE. Next: open Configurator once, or set IPAGRAB_CACHE to an existing directory."; return 1 + else + [ -r "$CONFIGURATOR_HOME" ] && [ -x "$CONFIGURATOR_HOME" ] || { error "Configurator container is not readable: $CONFIGURATOR_HOME. Next: check its permissions, then retry."; return 1; } + fi + [ -d "$DEST" ] || { error "destination does not exist: $DEST. Next: create it or set IPAGRAB_DEST, then retry."; return 1; } + [ -w "$DEST" ] && [ -x "$DEST" ] || { error "destination is not writable: $DEST. Next: choose a writable IPAGRAB_DEST, then retry."; return 1; } +} + +START_PATHS=() +START_FINGERPRINTS=() +REJECTED_PATHS=() +REJECTED_FINGERPRINTS=() +LAST_CANDIDATE="" +snapshot_cache() { + local f + START_PATHS=(); START_FINGERPRINTS=(); REJECTED_PATHS=(); REJECTED_FINGERPRINTS=(); LAST_CANDIDATE="" + # ponytail: Configurator cache names do not contain newlines; use a normal + # find stream until that changes, then upgrade this boundary to NUL records. + while IFS= read -r f; do + START_PATHS+=("$f") + START_FINGERPRINTS+=("$(fingerprint_of "$f")") + done < <(find "$CACHE" -type f -name '*.ipa' 2>/dev/null) +} + +unchanged_from_start() { + local f=$1 now i + now=$(fingerprint_of "$f") + for ((i = 0; i < ${#START_PATHS[@]}; i++)); do + [ "$f" = "${START_PATHS[$i]}" ] && [ "$now" = "${START_FINGERPRINTS[$i]}" ] && return 0 + done + return 1 +} + +rejected_unchanged() { + local f=$1 now i + now=$(fingerprint_of "$f") + for ((i = 0; i < ${#REJECTED_PATHS[@]}; i++)); do + [ "$f" = "${REJECTED_PATHS[$i]}" ] && [ "$now" = "${REJECTED_FINGERPRINTS[$i]}" ] && return 0 + done + return 1 +} + +reject_candidate() { + local f=$1 now i + now=$(fingerprint_of "$f") + # ponytail: cache file counts are small, so a Bash 3.2-compatible linear scan + # wins here; upgrade both path tables together if that ceiling changes. + for ((i = 0; i < ${#REJECTED_PATHS[@]}; i++)); do + if [ "$f" = "${REJECTED_PATHS[$i]}" ]; then REJECTED_FINGERPRINTS[$i]=$now; return; fi + done + REJECTED_PATHS+=("$f"); REJECTED_FINGERPRINTS+=("$now") +} + +find_candidate() { + local f i seen_last=0 candidates=() + if [ ! -e "$CACHE" ]; then + if [ "$CACHE" = "$DEFAULT_CACHE" ] && [ -d "$CONFIGURATOR_HOME" ]; then + [ -r "$CONFIGURATOR_HOME" ] && [ -x "$CONFIGURATOR_HOME" ] && return 3 + error "Configurator container is no longer readable: $CONFIGURATOR_HOME. Next: check its permissions, then retry."; return 2 + fi + error "Configurator cache no longer exists: $CACHE. Next: restore it or set IPAGRAB_CACHE, then retry."; return 2 + fi + [ -d "$CACHE" ] || { error "Configurator cache path is not a directory: $CACHE. Next: fix IPAGRAB_CACHE, then retry."; return 2; } + [ -r "$CACHE" ] && [ -x "$CACHE" ] || { error "Configurator cache is no longer readable: $CACHE. Next: check its permissions, then retry."; return 2; } + [ -d "$DEST" ] || { error "destination no longer exists: $DEST. Next: recreate it or set IPAGRAB_DEST, then retry."; return 2; } + [ -w "$DEST" ] && [ -x "$DEST" ] || { error "destination is no longer writable: $DEST. Next: choose a writable IPAGRAB_DEST, then retry."; return 2; } + while IFS= read -r f; do + unchanged_from_start "$f" || rejected_unchanged "$f" || candidates+=("$f") + done < <(find "$CACHE" -type f -name '*.ipa' 2>/dev/null) + [ "${#candidates[@]}" -gt 0 ] || return 1 + for ((i = 0; i < ${#candidates[@]}; i++)); do + [ "$seen_last" -eq 1 ] && { printf '%s\n' "${candidates[$i]}"; return 0; } + [ "${candidates[$i]}" = "$LAST_CANDIDATE" ] && seen_last=1 + done + printf '%s\n' "${candidates[0]}" +} + +RESULT=""; RESULT_SIZE=""; COPY_TMP="" +copy_complete_ipa() { + local f=$1 tmp base stamp out suffix=0 + RESULT=""; RESULT_SIZE=""; COPY_TMP="" + archive_is_readable "$f" || return 1 + tmp=$(mktemp "$DEST/.ipagrab.XXXXXX") || { error "cannot create a temporary file in destination: $DEST. Next: check free space and permissions, then retry."; return 2; } + COPY_TMP=$tmp + if ! cp -c "$f" "$tmp" 2>/dev/null && ! cp "$f" "$tmp" 2>/dev/null; then + rm -f "$tmp"; COPY_TMP="" + [ -e "$f" ] || return 1 + error "cannot copy IPA to destination: $DEST. Next: check free space and permissions, then retry."; return 2 + fi + archive_is_readable "$tmp" || { rm -f "$tmp"; COPY_TMP=""; return 1; } + + base=$(basename "$f"); stamp=$(date +%H%M%S); out="$DEST/${stamp}_${base}" + while ! ln "$tmp" "$out" 2>/dev/null; do + if [ -e "$out" ] || [ -L "$out" ]; then + suffix=$((suffix + 1)); out="$DEST/${stamp}_${base%.ipa}_${suffix}.ipa" + else + rm -f "$tmp"; COPY_TMP=""; error "cannot save IPA in destination: $DEST. Next: check free space and permissions, then retry."; return 2 + fi + done + rm -f "$tmp"; COPY_TMP="" + RESULT="$out"; RESULT_SIZE=$(size_of "$out") +} RAINBOW_HUES=(196 208 226 46 51 129 165) rainbow_word() { # color each char of $1 with successive hues local s=$1 esc=$'\033' n=${#RAINBOW_HUES[@]} i out="" + [ "$USE_COLOR" -eq 0 ] && { printf '%s' "$s"; return; } for ((i = 0; i < ${#s}; i++)); do out+="${esc}[1;38;5;${RAINBOW_HUES[i % n]}m${s:i:1}"; done printf '%s%s' "$out" "$esc[0m" } show_banner() { # 3D rainbow ANSI-Shadow "ipagrab" - local reset=$'\033[0m' bold=$'\033[1m' i + local i local c=(196 208 226 46 51 129) local lines=( '██╗██████╗ █████╗ ██████╗ ██████╗ █████╗ ██████╗ ' @@ -26,23 +184,39 @@ show_banner() { # 3D rainbow ANSI-Shadow "ipagrab" '██║██║ ██║ ██║╚██████╔╝██║ ██║██║ ██║██████╔╝' '╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ') printf '\n' - for i in "${!lines[@]}"; do printf ' %s\033[38;5;%sm%s%s\n' "$bold" "${c[$i]}" "${lines[$i]}" "$reset"; done - printf ' %s\033[38;5;51m▓▒░ Apple Configurator .ipa grabber ░▒▓%s\n' "$bold" "$reset" + for i in "${!lines[@]}"; do + if [ "$USE_COLOR" -eq 1 ]; then printf ' %s\033[38;5;%sm%s%s\n' "$BOLD" "${c[$i]}" "${lines[$i]}" "$RESET" + else printf ' %s\n' "${lines[$i]}"; fi + done + printf ' %s%s▓▒░ Apple Configurator .ipa grabber ░▒▓%s\n' "$BOLD" "$CYAN" "$RESET" +} + +show_setup() { + printf '\n Watch: %s\n Save: %s\n' "$CACHE" "$DEST" + if [ -d "$CACHE" ]; then printf ' Existing cache files are ignored until Configurator changes them.\n' + else printf ' Cache status: waiting for Configurator to create it.\n'; fi } ready_menu() { # READY? menu; returns 0=GO! 1=EXIT local options=("GO!" "EXIT") selected=0 n=2 first=1 i key rest - printf '\nREADY? (Up/Down to choose, Enter to confirm)\n' + printf '\n' + state_line "READY" "Choose GO! and press Enter. Once WATCHING, start Add → Apps… in Configurator." + printf ' Keys: Up/Down or 1/2 selects; Enter confirms.\n' + trap 'tput cnorm 2>/dev/null; printf "\n"; state_line "STOPPED" "Interrupted."; exit 130' INT TERM HUP tput civis 2>/dev/null - trap 'tput cnorm 2>/dev/null; printf "\n"; exit 130' INT while true; do [ "$first" -eq 1 ] || tput cuu "$n" 2>/dev/null; first=0 for i in "${!options[@]}"; do - if [ "$i" -eq "$selected" ]; then printf '\r\033[K \033[7m %d. %s \033[0m\n' "$((i + 1))" "${options[$i]}" + if [ "$i" -eq "$selected" ] && [ "$USE_COLOR" -eq 1 ]; then printf '\r\033[K \033[7m %d. %s \033[0m\n' "$((i + 1))" "${options[$i]}" + elif [ "$i" -eq "$selected" ]; then printf '\r\033[K > %d. %s\n' "$((i + 1))" "${options[$i]}" else printf '\r\033[K %d. %s\n' "$((i + 1))" "${options[$i]}"; fi done # arrow = 3-byte ESC seq; read 2 trailing bytes (no fractional -t on bash 3.2) - IFS= read -rsn1 key + if ! IFS= read -rsn1 key; then + tput cnorm 2>/dev/null; trap - INT TERM HUP + state_line "STOPPED" "Input closed; exiting." + return 1 + fi [ "$key" = $'\x1b' ] && { IFS= read -rsn2 rest; key+="$rest"; } case "$key" in $'\x1b[A'|$'\x1bOA') selected=$(( (selected - 1 + n) % n ));; @@ -51,7 +225,7 @@ ready_menu() { # READY? menu; returns 0=GO! 1=EXIT ''|$'\r'|$'\n') break;; esac done - tput cnorm 2>/dev/null; trap - INT + tput cnorm 2>/dev/null; trap - INT TERM HUP return "$selected" } @@ -62,71 +236,93 @@ WAVE_LEVELS=(4 5 6 6 7 7 7 6 6 5 4 2 1 1 0 0 0 1 1 2) WAVE_HUES=(196 202 208 214 220 226 190 46 48 51 45 39 33 63 99 129 165 201 198 197) WAVE_WIDTH=46; WAVE_FRAMES=(); WAVE_NFRAMES=0 build_wave_frames() { - local esc=$'\033' reset=$'\033[0m' n=${#WAVE_LEVELS[@]} o x line + local esc=$'\033' n=${#WAVE_LEVELS[@]} o x line WAVE_NFRAMES=$n for ((o = 0; o < n; o++)); do line="" - for ((x = 0; x < WAVE_WIDTH; x++)); do line+="${esc}[38;5;${WAVE_HUES[(x + o) % n]}m${WAVE_BARS[WAVE_LEVELS[(x + o) % n]]}"; done - WAVE_FRAMES[$o]="${line}${reset}" + for ((x = 0; x < WAVE_WIDTH; x++)); do + if [ "$USE_COLOR" -eq 1 ]; then line+="${esc}[38;5;${WAVE_HUES[(x + o) % n]}m"; fi + line+="${WAVE_BARS[WAVE_LEVELS[(x + o) % n]]}" + done + WAVE_FRAMES[$o]="${line}${RESET}" done } watch_and_grab() { # animate + grab; returns 0=grabbed 2=stopped - local bold=$'\033[1m' reset=$'\033[0m' cyan=$'\033[38;5;51m' # bash `read -n1` blocks, freezing the wave; instead set tty min0/time0 and poll # one byte with `dd`. Key/scan run every WORK_EVERY frames so the wave stays smooth. - local saved=$(stty -g 2>/dev/null) + local saved elapsed + saved=$(stty -g 2>/dev/null) + _wg_cleanup() { + [ -n "$COPY_TMP" ] && rm -f "$COPY_TMP"; COPY_TMP="" + [ -n "$saved" ] && stty "$saved" 2>/dev/null + tput cnorm 2>/dev/null + } + trap '_wg_cleanup; trap - INT TERM HUP; printf "\n"; state_line "STOPPED" "Interrupted; terminal restored."; exit 130' INT TERM HUP stty -echo -icanon min 0 time 0 2>/dev/null tput civis 2>/dev/null [ "$WAVE_NFRAMES" -eq 0 ] && build_wave_frames - _wg_cleanup() { stty "$saved" 2>/dev/null; tput cnorm 2>/dev/null; } - trap '_wg_cleanup; trap - INT; printf "\n"; exit 130' INT - printf '\n %s%s▓▒░ WATCHING ░▒▓%s press %s[S]%s to STOP\n\n' "$bold" "$cyan" "$reset" "$bold" "$reset" + printf '\n' + state_line "WATCHING" "Waiting for a new IPA. Start Add → Apps… in Configurator now." + printf ' Keys: S, Q, or Escape stops and returns to the menu.\n\n' SECONDS=0 - local WORK_EVERY=3 t=0 up="" status="scanning Configurator cache…" result="" result_sz="" f key - local last stable cur src_sz base out out_sz + local WORK_EVERY=3 t=0 up="" status="[WATCHING] Waiting for a new IPA…" f key scan_status copy_status + snapshot_cache while true; do if [ $(( t % WORK_EVERY )) -eq 0 ]; then key=$(dd bs=1 count=1 2>/dev/null) case "$key" in - s|S|q|Q|$'\033') _wg_cleanup; trap - INT - printf '\n %s⏹ STOPPED%s — returning to menu\n' "$bold" "$reset"; return 2 ;; + s|S|q|Q|$'\033') _wg_cleanup; trap - INT TERM HUP + printf '\n'; state_line "STOPPED" "No IPA saved; returning to the menu."; return 2 ;; esac - f=$(find "$CACHE" -name '*.ipa' 2>/dev/null | head -1) - if [ -n "$f" ] && [ -e "$f" ]; then - status="downloading… ⬇ settling" - last=-1; stable=0 - while :; do # wait for size to settle = complete download - cur=$(size_of "$f"); [ ! -e "$f" ] && break - if [ "$cur" = "$last" ] && [ "$cur" -ge "$MIN_BYTES" ]; then - stable=$((stable + 1)); [ "$stable" -ge "$STABLE_POLLS" ] && break - else stable=0; fi - last=$cur; sleep 0.2 - done - if [ -e "$f" ]; then - src_sz=$(size_of "$f"); base="$(basename "$f")"; out="$DEST/$(date +%H%M%S)_${base}" - if cp -c "$f" "$out" 2>/dev/null || cp "$f" "$out" 2>/dev/null; then - out_sz=$(size_of "$out") - if [ "$out_sz" = "$src_sz" ] && [ "$out_sz" -ge "$MIN_BYTES" ]; then - result="$out"; result_sz="$out_sz"; break - else rm -f "$out"; fi # truncated mid-copy; keep watching - fi - fi + f=$(find_candidate); scan_status=$? + if [ "$scan_status" -eq 0 ]; then + LAST_CANDIDATE=$f + status="[VALIDATING] Waiting for $(basename "$f") to finish…" + printf '%s\r %s\n\r \033[K%s[S/Q/Esc] STOP%s %02d:%02d %s\n' \ + "$up" "${WAVE_FRAMES[t % WAVE_NFRAMES]}" "$BOLD" "$RESET" "$((SECONDS / 60))" "$((SECONDS % 60))" "$status" + up=$'\033[2A'; t=$((t + 1)) + copy_complete_ipa "$f"; copy_status=$? + [ "$copy_status" -eq 0 ] && break + [ "$copy_status" -eq 2 ] && { _wg_cleanup; trap - INT TERM HUP; printf '\n'; return 1; } + reject_candidate "$f" + status="[WATCHING] Candidate incomplete; waiting for more data…" + elif [ "$scan_status" -eq 2 ]; then + _wg_cleanup; trap - INT TERM HUP; printf '\n'; return 1 + elif [ "$scan_status" -eq 3 ]; then + status="[WATCHING] Waiting for Configurator to create its cache…" fi fi # one printf; $up does cursor-up-2 via raw escape (no per-frame tput fork) - printf '%s\r %s\n\r \033[K%s[S] STOP%s ⏱ %02d:%02d %s\n' \ - "$up" "${WAVE_FRAMES[t % WAVE_NFRAMES]}" "$bold" "$reset" "$((SECONDS / 60))" "$((SECONDS % 60))" "$status" + printf '%s\r %s\n\r \033[K%s[S/Q/Esc] STOP%s %02d:%02d %s\n' \ + "$up" "${WAVE_FRAMES[t % WAVE_NFRAMES]}" "$BOLD" "$RESET" "$((SECONDS / 60))" "$((SECONDS % 60))" "$status" up=$'\033[2A'; t=$((t + 1)); sleep 0.04 done - _wg_cleanup; trap - INT - printf '\n %s\n' "$(rainbow_word '▓▒░ ✔ GRABBED ░▒▓')" - printf ' %s%s%s %s(%s bytes)%s\n' "$cyan" "$(basename "$result")" "$reset" "$bold" "$result_sz" "$reset" - printf ' %s📂 Reminder:%s your .ipa is on the Desktop → %s%s%s\n' "$bold" "$reset" "$cyan" "$DEST" "$reset" - printf ' Returning to menu…\n' + elapsed=$SECONDS + _wg_cleanup; trap - INT TERM HUP + printf '\n %s\n' "$(rainbow_word '▓▒░ [GRABBED] Complete IPA saved ░▒▓')" + printf ' File: %s\n' "$(basename "$RESULT")" + printf ' Size: %s (%s bytes)\n' "$(human_size "$RESULT_SIZE")" "$RESULT_SIZE" + printf ' Destination: %s\n' "$DEST" + printf ' Elapsed: %02d:%02d\n' "$((elapsed / 60))" "$((elapsed % 60))" + printf ' Next: choose GO! to watch for another IPA, or EXIT.\n' } +if [ "${IPAGRAB_SOURCE_ONLY:-}" = 1 ]; then return 0 2>/dev/null || exit 0; fi +[ "$#" -le 1 ] || { error "too many arguments. Next: run ipagrab --help for usage."; exit 2; } +case "${1:-}" in + -h|--help) show_help; exit 0;; + '') ;; + *) error "unknown option: $1"; printf 'Run ipagrab --help for usage.\n' >&2; exit 2;; +esac +preflight || exit 1 show_banner +show_setup while true; do - if ready_menu; then watch_and_grab; else echo "Exiting."; exit 0; fi + if ready_menu; then + watch_and_grab; watch_status=$? + [ "$watch_status" -eq 1 ] && exit 1 + else + state_line "STOPPED" "Session ended; terminal restored."; exit 0 + fi done diff --git a/tests/test.sh b/tests/test.sh new file mode 100644 index 0000000..22cfa2d --- /dev/null +++ b/tests/test.sh @@ -0,0 +1,151 @@ +#!/bin/bash +set -u + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +TMP=$(mktemp -d "${TMPDIR:-/tmp}/ipagrab-test.XXXXXX") || exit 1 +trap 'rm -rf "$TMP"' EXIT +export IPAGRAB_CACHE="$TMP/cache with space" IPAGRAB_DEST="$TMP/dest with space" IPAGRAB_SOURCE_ONLY=1 NO_COLOR=1 +mkdir -p "$IPAGRAB_CACHE" "$IPAGRAB_DEST" "$TMP/fixture/Payload" +. "$ROOT/ipagrab" + +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } +assert_file() { [ -f "$1" ] || fail "missing file: $1"; } +assert_count() { + local expected=$1 dir=$2 actual + actual=$(find "$dir" -type f ! -name '.ipagrab.*' | wc -l | tr -d ' ') + [ "$actual" = "$expected" ] || fail "expected $expected output file(s), found $actual" +} +make_ipa() { + local out=$1 + printf 'test payload\n' > "$TMP/fixture/Payload/app" + (cd "$TMP/fixture" && zip -qr "$out" Payload) || fail "could not create test IPA" +} + +validate_paths || fail "temporary cache and destination failed preflight" +[ "$(human_size 1536)" = "1.5 KiB" ] || fail "human-readable size is incorrect" +PLAIN_SCREEN=$(show_banner; show_setup; state_line "READY" "test") +ESC=$(printf '\033') +case "$PLAIN_SCREEN" in *"$ESC"*) fail "NO_COLOR screen contains ANSI styling";; esac +case "$PLAIN_SCREEN" in *"Watch: $IPAGRAB_CACHE"*"Save: $IPAGRAB_DEST"*) ;; *) fail "first screen omits configured paths";; esac +REAL_CACHE=$CACHE +CACHE="$TMP/missing-cache" +PREFLIGHT_ERROR=$(validate_paths 2>&1) && fail "missing cache passed preflight" +case "$PREFLIGHT_ERROR" in *'cache does not exist'*) ;; *) fail "missing cache error was unclear";; esac +CACHE=$REAL_CACHE +CACHE='-relative-cache' +PREFLIGHT_ERROR=$(validate_paths 2>&1) && fail "relative cache path passed preflight" +case "$PREFLIGHT_ERROR" in *'cache path must be absolute'*) ;; *) fail "relative cache error was unclear";; esac +CACHE=$REAL_CACHE + +REAL_DEFAULT_CACHE=$DEFAULT_CACHE +REAL_CONFIGURATOR_HOME=$CONFIGURATOR_HOME +FUTURE_CACHE="$TMP/future-cache" +FUTURE_HOME="$TMP/configurator-home" +mkdir "$FUTURE_HOME" +CACHE=$FUTURE_CACHE; DEFAULT_CACHE=$FUTURE_CACHE; CONFIGURATOR_HOME=$FUTURE_HOME +validate_paths || fail "missing default cache should be allowed while Configurator home exists" +find_candidate >/dev/null 2>&1; CANDIDATE_STATUS=$? +[ "$CANDIDATE_STATUS" = 3 ] || fail "missing default cache did not enter waiting state" +mkdir "$FUTURE_CACHE" +find_candidate >/dev/null 2>&1; CANDIDATE_STATUS=$? +[ "$CANDIDATE_STATUS" = 1 ] || fail "newly created empty cache was not scanned" +CACHE=$REAL_CACHE; DEFAULT_CACHE=$REAL_DEFAULT_CACHE; CONFIGURATOR_HOME=$REAL_CONFIGURATOR_HOME + +make_ipa "$IPAGRAB_CACHE/stale.ipa" +snapshot_cache +[ -z "$(find_candidate)" ] || fail "stale IPA was selected" + +printf 'not a zip\n' > "$IPAGRAB_CACHE/partial.ipa" +[ "$(find_candidate)" = "$IPAGRAB_CACHE/partial.ipa" ] || fail "new invalid IPA was not selected for validation" +copy_complete_ipa "$IPAGRAB_CACHE/partial.ipa" && fail "invalid IPA was copied" +reject_candidate "$IPAGRAB_CACHE/partial.ipa" +assert_count 0 "$IPAGRAB_DEST" + +printf 'still growing\n' >> "$IPAGRAB_CACHE/partial.ipa" +LAST_CANDIDATE="$IPAGRAB_CACHE/partial.ipa" +make_ipa "$IPAGRAB_CACHE/complete.ipa" +[ "$(find_candidate)" = "$IPAGRAB_CACHE/complete.ipa" ] || fail "growing invalid IPA starved a later valid candidate" +copy_complete_ipa "$IPAGRAB_CACHE/complete.ipa" || fail "complete IPA was not copied" +[ -z "$COPY_TMP" ] || fail "successful copy left temporary state" +assert_count 1 "$IPAGRAB_DEST" +assert_file "$RESULT" +unzip -tqq "$RESULT" || fail "copied IPA is not a readable ZIP" + +LAST_CANDIDATE="$IPAGRAB_CACHE/complete.ipa" +[ "$(find_candidate)" = "$IPAGRAB_CACHE/partial.ipa" ] || fail "changed rejected IPA was not reconsidered" +reject_candidate "$IPAGRAB_CACHE/partial.ipa" +assert_count 1 "$IPAGRAB_DEST" + +rm -f "$IPAGRAB_DEST"/* +make_ipa "$IPAGRAB_CACHE/collision.ipa" +date() { printf '120000\n'; } +printf 'keep me\n' > "$IPAGRAB_DEST/120000_collision.ipa" +copy_complete_ipa "$IPAGRAB_CACHE/collision.ipa" || fail "collision copy failed" +[ "$(cat "$IPAGRAB_DEST/120000_collision.ipa")" = "keep me" ] || fail "existing destination was overwritten" +assert_file "$IPAGRAB_DEST/120000_collision_1.ipa" +unzip -tqq "$IPAGRAB_DEST/120000_collision_1.ipa" || fail "collision copy is not a readable ZIP" + +HELP=$( + unset IPAGRAB_SOURCE_ONLY + "$ROOT/ipagrab" --help +) || fail "--help failed" +case "$HELP" in *'Usage: ipagrab [--help]'*) ;; *) fail "--help output is missing usage";; esac +case "$HELP" in *"$ESC"*) fail "NO_COLOR help contains ANSI styling";; esac +( + unset IPAGRAB_SOURCE_ONLY + "$ROOT/ipagrab" --help extra >/dev/null 2>&1 +) && fail "ipagrab accepted extra arguments" + +NONINTERACTIVE=$( + unset IPAGRAB_SOURCE_ONLY + "$ROOT/ipagrab" &1 +) +NONINTERACTIVE_STATUS=$? +[ "$NONINTERACTIVE_STATUS" -ne 0 ] || fail "non-interactive run unexpectedly succeeded" +case "$NONINTERACTIVE" in *'interactive terminal is required'*'Next:'*) ;; *) fail "non-interactive error lacks a next step";; esac +case "$NONINTERACTIVE" in *"$ESC"*) fail "non-interactive error contains ANSI styling";; esac + +uname() { printf 'Linux\n'; } +PREFLIGHT_ERROR=$(preflight 2>&1) && fail "unsupported platform passed preflight" +case "$PREFLIGHT_ERROR" in *'macOS is required'*) ;; *) fail "unsupported platform error was unclear";; esac +unset -f uname + +command() { + if [ "$1" = "-v" ] && [ "$2" = "unzip" ]; then return 1; fi + builtin command "$@" +} +PREFLIGHT_ERROR=$(preflight 2>&1) && fail "missing command passed preflight" +case "$PREFLIGHT_ERROR" in *'required command not found: unzip'*) ;; *) fail "missing command error was unclear";; esac +unset -f command + +INSTALL_HOME="$TMP/install-home" +INSTALL_BIN="$INSTALL_HOME/bin" +INSTALL_TARGET="$INSTALL_BIN/ipagrab" +INSTALL_PATH="$INSTALL_BIN:/usr/bin:/bin" +mkdir -p "$INSTALL_BIN" "$INSTALL_HOME/.local/bin" +run_install() { HOME="$INSTALL_HOME" PATH="$INSTALL_PATH" /bin/bash "$ROOT/install.sh" "$@"; } + +printf 'keep me\n' > "$INSTALL_TARGET" +run_install >/dev/null 2>&1 && fail "installer overwrote an unrelated file" +[ "$(cat "$INSTALL_TARGET")" = "keep me" ] || fail "installer changed an unrelated file" +rm -f "$INSTALL_TARGET" + +ln -s "$TMP/unrelated" "$INSTALL_TARGET" +run_install >/dev/null 2>&1 && fail "installer overwrote an unrelated symlink" +[ "$(readlink "$INSTALL_TARGET")" = "$TMP/unrelated" ] || fail "installer changed an unrelated symlink" +run_install uninstall >/dev/null || fail "uninstall failed with an unrelated symlink" +[ -L "$INSTALL_TARGET" ] || fail "uninstall removed an unrelated symlink" +rm -f "$INSTALL_TARGET" + +run_install >/dev/null || fail "install failed" +[ "$(readlink "$INSTALL_TARGET")" = "$ROOT/ipagrab" ] || fail "install created the wrong symlink" +run_install >/dev/null || fail "repeat install failed" +run_install uninstall extra >/dev/null 2>&1 && fail "uninstall accepted extra arguments" +[ "$(readlink "$INSTALL_TARGET")" = "$ROOT/ipagrab" ] || fail "invalid uninstall changed the managed symlink" +ln -s "$TMP/unrelated" "$INSTALL_HOME/.local/bin/ipagrab" +run_install uninstall >/dev/null || fail "uninstall failed" +[ ! -e "$INSTALL_TARGET" ] && [ ! -L "$INSTALL_TARGET" ] || fail "uninstall left its managed symlink" +[ -L "$INSTALL_HOME/.local/bin/ipagrab" ] || fail "uninstall removed an unrelated symlink" +run_install uninstall >/dev/null || fail "repeat uninstall failed" + +printf 'PASS: grabbing, installation, and terminal output\n'