Last active
August 8, 2026 07:05
-
-
Save addohm/4bcab541bd1e25fc04a52e25d486061e to your computer and use it in GitHub Desktop.
linux ipad utility
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 | |
| # ipad-util — manage files on an iOS app's Documents folder over USB. | |
| # Works with any iOS app that opts into file sharing (UIFileSharingEnabled). | |
| # | |
| # Subcommands: | |
| # put <path>... Transfer files/dirs into Documents (dirs walked manually). | |
| # get <remote> [local] Pull a file/dir from device. | |
| # ls [remote] List remote contents (default: Documents). | |
| # rm [-f] <remote>... Delete files/dirs (prompts unless -f). | |
| # mv <old> <new> Rename or move on device. | |
| # mkdir <path> Create directory on device (recursive). | |
| # sync <local-dir> Push only files not already present (name+size compare). | |
| # info Device info: model, OS, name, free space, battery. | |
| # space Free / total storage on device. | |
| # battery Battery percentage and charging state. | |
| # apps List this device's apps that allow file sharing. | |
| # screenshot [file.png] Capture screenshot (default: timestamped PNG). | |
| # mount [mountpoint] ifuse-mount the app's Documents folder. | |
| # unmount [mountpoint] fusermount -u. Aliases: umount, u. | |
| # status Show device, app, and mount state. | |
| # doctor Check which required/optional tools are installed. | |
| # | |
| # Global flags (before subcommand): | |
| # -a, --app <app> Which app's storage to use: a short name | |
| # ("chrome", "vlc") or a full bundle ID. | |
| # -u, --udid <udid> Override device UDID. | |
| # -y, --yes Skip the "not enough space" / rm confirmation prompts. | |
| # -q, --no-progress Disable the live progress bar (also auto-disabled if non-tty). | |
| # | |
| # Which app am I talking to? | |
| # Every command operates inside ONE app's private storage, chosen with -a. | |
| # The default is org.videolan.vlc-ios (VLC) — if that app isn't installed, | |
| # the very first command will fail. Run `ipad-util apps` to see what this | |
| # device actually offers, then use -a or set BUNDLE in the config file. | |
| # | |
| # -a accepts the name you see in the Files app, matched case-insensitively | |
| # against the apps on this device — no need to memorize bundle IDs: | |
| # ipad-util -a chrome ls | |
| # ipad-util -a vlc get "Documents/movie.mkv" ~/Videos | |
| # A value containing a dot is used verbatim as a bundle ID, so the long | |
| # form still works: -a com.google.chrome.ios | |
| # | |
| # Remote paths | |
| # Paths are relative to the app's container, and the shared area is | |
| # Documents/ — so a file that appears in Files as "On My iPad/Chrome/a.pdf" | |
| # is addressed as: | |
| # ipad-util -a chrome get "Documents/a.pdf" | |
| # A leading slash refers to the container root, which iOS denies: | |
| # "Documents/a.pdf" correct | |
| # "/Documents/a.pdf" fails with Permission denied | |
| # `put` and `sync` prepend Documents/ automatically; get/ls/rm/mv/mkdir | |
| # take the full path, so they need the Documents/ prefix spelled out. | |
| # | |
| # What this tool CANNOT reach | |
| # Folders you create yourself at the top level of "On My iPhone" / | |
| # "On My iPad" in the Files app belong to the Files app, and iOS shares | |
| # no USB path to them — no bundle ID or path reaches that storage. | |
| # Only folders belonging to the apps in `ipad-util apps` are accessible. | |
| # To retrieve such a file, move it into one of those folders on the device | |
| # first (in Files, drag it onto e.g. the Chrome folder). | |
| # | |
| # Configuration precedence (highest wins): | |
| # command-line flag > env var > ~/.config/ipad-util/config > built-in default | |
| # Config file is shell-sourced; recognized keys: BUNDLE, UDID, MOUNTPOINT | |
| # Env vars: IPAD_BUNDLE, IPAD_UDID, IPAD_MOUNTPOINT | |
| set -euo pipefail | |
| # --- config loading ----------------------------------------------------------- | |
| CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/ipad-util/config" | |
| if [ -f "$CONFIG_FILE" ]; then | |
| # shellcheck disable=SC1090 | |
| . "$CONFIG_FILE" | |
| fi | |
| BUNDLE="${IPAD_BUNDLE:-${BUNDLE:-org.videolan.vlc-ios}}" | |
| UDID_OVERRIDE="${IPAD_UDID:-${UDID:-}}" | |
| DEFAULT_MOUNT_BASE="${IPAD_MOUNTPOINT:-${MOUNTPOINT:-}}" | |
| # --- colors / progress -------------------------------------------------------- | |
| # ANSI colors. Disabled when stdout is not a tty, when NO_COLOR is set, or for dumb terminals. | |
| if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && [ "${TERM:-}" != "dumb" ]; then | |
| CLR_RESET=$'\e[0m' | |
| CLR_DIM=$'\e[2m' | |
| CLR_BOLD=$'\e[1m' | |
| CLR_FILL=$'\e[36m' # cyan — transferring | |
| CLR_DONE=$'\e[32m' # green — finished | |
| CLR_WARN=$'\e[33m' # yellow | |
| CLR_FAIL=$'\e[31m' # red | |
| CLR_CLEAR=$'\e[K' # clear to end of line | |
| else | |
| CLR_RESET= CLR_DIM= CLR_BOLD= CLR_FILL= CLR_DONE= CLR_WARN= CLR_FAIL= CLR_CLEAR= | |
| fi | |
| # Transfer state. Populated by cmd_put / cmd_get / cmd_sync before the transfer | |
| # starts; consumed by render_frame and the per-file workers. | |
| _TX_TOTAL=0 | |
| _TX_DONE=0 | |
| _TX_START=0 | |
| _TX_FILE_IDX=0 | |
| _TX_FILE_COUNT=0 | |
| _TX_PROGRESS=0 | |
| _TX_FIRST_FRAME=1 | |
| _TX_NAMES=() # display name per file | |
| _TX_SIZES=() # bytes per file | |
| _TX_STATUS=() # 0=pending 1=active 2=done 3=failed | |
| _TX_FAIL_NAMES=() # "<name>: <afcclient error>" for each failed transfer | |
| # Braille spinner frames for the active file indicator. | |
| _TX_SPIN_FRAMES=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏') | |
| _TX_SPIN_IDX=0 | |
| format_time() { | |
| local s="${1:-0}" | |
| [ "$s" -lt 0 ] && s=0 | |
| printf '%02d:%02d:%02d' $((s/3600)) $((s%3600/60)) $((s%60)) | |
| } | |
| # Repeat a single-char string N times. Used for the bar fill. | |
| _repeat() { | |
| local ch="$1" n="$2" out="" | |
| while [ "$n" -gt 0 ]; do out+="$ch"; n=$((n-1)); done | |
| printf '%s' "$out" | |
| } | |
| # Render the bar line: " [████░░░░] PCT% done/total rate/s ETA HH:MM:SS" | |
| # Ends with \e[K\n so the caller can move on to the next line. | |
| _render_bar_line() { | |
| local done="$1" total="$2" rate="$3" | |
| if [ "$total" -le 0 ]; then printf "${CLR_CLEAR}\n"; return; fi | |
| local pct_int=$((done * 100 / total)) | |
| [ "$pct_int" -gt 100 ] && pct_int=100 | |
| local pct_str; pct_str=$(awk "BEGIN { printf \"%5.1f\", $done * 100.0 / $total }") | |
| local bar_len=24 | |
| local filled=$((bar_len * pct_int / 100)) | |
| [ "$filled" -gt "$bar_len" ] && filled="$bar_len" | |
| local fill_color="$CLR_FILL" | |
| [ "$pct_int" -ge 100 ] && fill_color="$CLR_DONE" | |
| local bar_filled bar_empty | |
| bar_filled=$(_repeat '█' "$filled") | |
| bar_empty=$(_repeat '░' "$((bar_len - filled))") | |
| local eta_str="--:--:--" | |
| if [ "${rate:-0}" -gt 0 ] && [ "$total" -gt "$done" ]; then | |
| eta_str=$(format_time "$(( (total - done) / rate ))") | |
| fi | |
| printf " ${CLR_DIM}[${CLR_RESET}${fill_color}%s${CLR_RESET}${CLR_DIM}%s]${CLR_RESET} ${CLR_BOLD}%s%%${CLR_RESET} %s/%s %s/s ETA %s${CLR_CLEAR}\n" \ | |
| "$bar_filled" "$bar_empty" "$pct_str" \ | |
| "$(human_bytes "$done")" "$(human_bytes "$total")" \ | |
| "$(human_bytes "${rate:-0}")" "$eta_str" | |
| } | |
| # Render one file line based on its status. | |
| _render_file_line() { | |
| local idx="$1" spinner="$2" | |
| local status="${_TX_STATUS[$idx]:-0}" | |
| local icon icon_color name_color | |
| case "$status" in | |
| 0) icon='·'; icon_color="$CLR_DIM"; name_color="$CLR_DIM" ;; | |
| 1) icon="$spinner"; icon_color="$CLR_FILL"; name_color="$CLR_RESET" ;; | |
| 2) icon='✓'; icon_color="$CLR_DONE"; name_color="$CLR_RESET" ;; | |
| *) icon='?'; icon_color="$CLR_FAIL"; name_color="$CLR_RESET" ;; | |
| esac | |
| local name="${_TX_NAMES[$idx]}" | |
| local size; size=$(human_bytes "${_TX_SIZES[$idx]}") | |
| # Truncate very long names so the line doesn't wrap. | |
| local cols=${COLUMNS:-$(tput cols 2>/dev/null || echo 100)} | |
| local max_name=$((cols - 20)) | |
| [ "$max_name" -lt 20 ] && max_name=20 | |
| if [ "${#name}" -gt "$max_name" ]; then | |
| name="${name:0:$((max_name-1))}…" | |
| fi | |
| printf " %s%s%s %s%s%s %s(%s)%s${CLR_CLEAR}\n" \ | |
| "$icon_color" "$icon" "$CLR_RESET" \ | |
| "$name_color" "$name" "$CLR_RESET" \ | |
| "$CLR_DIM" "$size" "$CLR_RESET" | |
| } | |
| # Draw the full transfer frame: bar line + one row per file. | |
| # First frame just prints; subsequent frames move cursor up to overwrite. | |
| render_frame() { | |
| local done="$1" total="$2" rate="$3" | |
| local n_files=${#_TX_NAMES[@]} | |
| local n_lines=$((n_files + 1)) | |
| if [ "$_TX_FIRST_FRAME" = "1" ]; then | |
| _TX_FIRST_FRAME=0 | |
| else | |
| printf '\e[%dA\r' "$n_lines" | |
| fi | |
| _TX_SPIN_IDX=$(( (_TX_SPIN_IDX + 1) % ${#_TX_SPIN_FRAMES[@]} )) | |
| local spinner="${_TX_SPIN_FRAMES[$_TX_SPIN_IDX]}" | |
| _render_bar_line "$done" "$total" "$rate" | |
| local i | |
| for ((i=0; i<n_files; i++)); do | |
| _render_file_line "$i" "$spinner" | |
| done | |
| } | |
| # Overwrite the bar line with a final summary; mark every file ✓. | |
| render_done() { | |
| [ "$_TX_PROGRESS" = "1" ] || return 0 | |
| local n_files=${#_TX_NAMES[@]} | |
| local n_lines=$((n_files + 1)) | |
| if [ "$_TX_FIRST_FRAME" != "1" ]; then | |
| printf '\e[%dA\r' "$n_lines" | |
| fi | |
| local elapsed=$(($(date +%s) - _TX_START)) | |
| local avg=0 | |
| [ "$elapsed" -gt 0 ] && avg=$((_TX_TOTAL / elapsed)) | |
| local bar; bar=$(_repeat '█' 24) | |
| printf " ${CLR_DIM}[${CLR_RESET}${CLR_DONE}%s${CLR_RESET}${CLR_DIM}]${CLR_RESET} ${CLR_BOLD}100.0%%${CLR_RESET} %s transferred in %s (avg %s/s)${CLR_CLEAR}\n" \ | |
| "$bar" "$(human_bytes "$_TX_TOTAL")" "$(format_time "$elapsed")" "$(human_bytes "$avg")" | |
| # Preserve failed rows (status 3) so they stay marked, not silently ✓'d. | |
| local i | |
| for ((i=0; i<n_files; i++)); do | |
| [ "${_TX_STATUS[$i]:-0}" = "3" ] || _TX_STATUS[$i]=2 | |
| done | |
| for ((i=0; i<n_files; i++)); do _render_file_line "$i" '✓'; done | |
| } | |
| # Reset progress state. Caller must populate _TX_NAMES/_TX_SIZES first. | |
| tx_init() { | |
| _TX_TOTAL="$1" | |
| local n=${#_TX_NAMES[@]} | |
| _TX_FILE_COUNT="$n" | |
| _TX_STATUS=() | |
| _TX_FAIL_NAMES=() | |
| local i | |
| for ((i=0; i<n; i++)); do _TX_STATUS+=(0); done | |
| _TX_DONE=0 | |
| _TX_FILE_IDX=0 | |
| _TX_START=$(date +%s) | |
| _TX_FIRST_FRAME=1 | |
| _TX_SPIN_IDX=0 | |
| # Multi-line UI needs a tty. Also fall back to per-line mode if the file list | |
| # would exceed terminal height (cursor-up positioning breaks on scroll). | |
| local term_lines=${LINES:-$(tput lines 2>/dev/null || echo 24)} | |
| if [ -t 1 ] && [ "${NO_PROGRESS:-0}" != "1" ] && [ "$((n + 4))" -le "$term_lines" ]; then | |
| _TX_PROGRESS=1 | |
| else | |
| _TX_PROGRESS=0 | |
| fi | |
| } | |
| # --- helpers ------------------------------------------------------------------ | |
| die() { echo "ipad-util: $*" >&2; exit 1; } | |
| warn() { echo "ipad-util: $*" >&2; } | |
| # Detect the system's package manager so install hints work on multiple distros. | |
| _pm() { | |
| if command -v dnf >/dev/null 2>&1; then echo dnf | |
| elif command -v apt-get >/dev/null 2>&1; then echo apt | |
| elif command -v pacman >/dev/null 2>&1; then echo pacman | |
| elif command -v zypper >/dev/null 2>&1; then echo zypper | |
| else echo "" | |
| fi | |
| } | |
| # Return a distro-appropriate install command for a given tool. | |
| install_hint() { | |
| local tool="$1" | |
| case "$(_pm):$tool" in | |
| # --- Fedora / RHEL / Nobara (dnf) --- | |
| dnf:afcclient|dnf:idevice_id|dnf:ideviceinfo|dnf:idevicescreenshot) | |
| echo "sudo dnf install libimobiledevice" ;; | |
| dnf:ifuse) echo "sudo dnf install ifuse" ;; | |
| dnf:fusermount) echo "sudo dnf install fuse" ;; | |
| dnf:ideviceinstaller) echo "not in Fedora repos — build from source: https://github.com/libimobiledevice/ideviceinstaller" ;; | |
| dnf:numfmt) echo "sudo dnf install coreutils" ;; | |
| dnf:jq) echo "sudo dnf install jq" ;; | |
| # --- Debian / Ubuntu (apt) --- | |
| apt:afcclient|apt:idevice_id|apt:ideviceinfo|apt:idevicescreenshot) | |
| echo "sudo apt install libimobiledevice-utils" ;; | |
| apt:ifuse) echo "sudo apt install ifuse" ;; | |
| apt:fusermount) echo "sudo apt install fuse" ;; | |
| apt:ideviceinstaller) echo "sudo apt install ideviceinstaller" ;; | |
| apt:numfmt) echo "sudo apt install coreutils" ;; | |
| apt:jq) echo "sudo apt install jq" ;; | |
| # --- Arch / Manjaro (pacman) --- | |
| pacman:afcclient|pacman:idevice_id|pacman:ideviceinfo|pacman:idevicescreenshot) | |
| echo "sudo pacman -S libimobiledevice" ;; | |
| pacman:ifuse) echo "AUR — yay -S ifuse (removed from official repos)" ;; | |
| pacman:fusermount) echo "sudo pacman -S fuse2" ;; | |
| pacman:ideviceinstaller) echo "sudo pacman -S ideviceinstaller" ;; | |
| pacman:numfmt) echo "sudo pacman -S coreutils" ;; | |
| pacman:jq) echo "sudo pacman -S jq" ;; | |
| # --- openSUSE (zypper), best-effort --- | |
| zypper:afcclient|zypper:idevice_id|zypper:ideviceinfo|zypper:idevicescreenshot) | |
| echo "sudo zypper install libimobiledevice-tools" ;; | |
| zypper:ifuse) echo "sudo zypper install ifuse" ;; | |
| zypper:fusermount) echo "sudo zypper install fuse" ;; | |
| zypper:ideviceinstaller) echo "sudo zypper install ideviceinstaller" ;; | |
| zypper:numfmt) echo "sudo zypper install coreutils" ;; | |
| zypper:jq) echo "sudo zypper install jq" ;; | |
| *) echo "install '$tool' via your package manager" ;; | |
| esac | |
| } | |
| need() { | |
| command -v "$1" >/dev/null 2>&1 && return | |
| die "missing tool: $1 ($(install_hint "$1"))" | |
| } | |
| # Non-fatal check used by `doctor`. Returns 0 if ok or optional-and-missing, 1 if required-and-missing. | |
| _doctor_check() { | |
| local tool="$1" purpose="$2" required="${3:-0}" | |
| if command -v "$tool" >/dev/null 2>&1; then | |
| printf " [ok] %-22s %s\n" "$tool" "$purpose" | |
| return 0 | |
| fi | |
| local hint; hint=$(install_hint "$tool") | |
| if [ "$required" = "1" ]; then | |
| printf " [FAIL] %-22s %s\n install: %s\n" "$tool" "$purpose" "$hint" | |
| return 1 | |
| fi | |
| printf " [skip] %-22s %s\n install: %s\n" "$tool" "$purpose" "$hint" | |
| return 0 | |
| } | |
| resolve_udid() { | |
| if [ -n "$UDID_OVERRIDE" ]; then | |
| echo "$UDID_OVERRIDE" | |
| return | |
| fi | |
| local udids | |
| mapfile -t udids < <(idevice_id -l 2>/dev/null) | |
| case "${#udids[@]}" in | |
| 0) die "no iOS device detected (plug in and trust the computer)" ;; | |
| 1) echo "${udids[0]}" ;; | |
| *) die "multiple devices detected; pass -u UDID or set IPAD_UDID. Found: ${udids[*]}" ;; | |
| esac | |
| } | |
| afc() { afcclient -u "$(resolve_udid)" --documents "$BUNDLE" "$@"; } | |
| info_key() { ideviceinfo -u "$(resolve_udid)" "$@" 2>/dev/null; } | |
| # Emit "<bundle-id><TAB><display-name>" for each app that sets | |
| # UIFileSharingEnabled. ifuse --list-apps is the only tool that reports this | |
| # without ideviceinstaller. Returns 1 if the list can't be produced or is empty. | |
| _sharing_apps_raw() { | |
| command -v ifuse >/dev/null 2>&1 || return 1 | |
| local rows | |
| rows=$(ifuse --list-apps 2>/dev/null | awk -F'","' ' | |
| NR == 1 { next } | |
| { | |
| id = $1; name = $3 | |
| gsub(/^"|"$/, "", id); gsub(/^"|"$/, "", name) | |
| if (id != "") printf "%s\t%s\n", id, name | |
| }') | |
| [ -n "$rows" ] || return 1 | |
| printf '%s\n' "$rows" | |
| } | |
| # Same list, formatted for display. | |
| list_sharing_apps() { | |
| local raw | |
| raw=$(_sharing_apps_raw) || return 1 | |
| printf '%s\n' "$raw" | awk -F'\t' '{ printf " %-30s %s\n", $1, $2 }' | |
| } | |
| # Let -a take a short name ("chrome", "vlc") instead of a full bundle ID. | |
| # A value containing a dot is treated as a literal bundle ID and left alone; | |
| # anything else is matched case-insensitively against this device's | |
| # file-sharing apps. Rewrites BUNDLE in place — it must NOT be called inside | |
| # $(...), or `die` would only exit the subshell. | |
| resolve_bundle() { | |
| local want="$BUNDLE" | |
| [ -n "$want" ] || return 0 | |
| case "$want" in *.*) return 0 ;; esac | |
| local raw | |
| if ! raw=$(_sharing_apps_raw); then | |
| die "cannot resolve app name '$want' — that needs ifuse to query the device. | |
| Pass the full bundle ID instead, or install ifuse: $(install_hint ifuse)" | |
| fi | |
| # Tier 1 exact name/id, tier 2 name prefix, tier 3 substring anywhere. | |
| # Only the best tier that matched is considered, so "chrome" doesn't become | |
| # ambiguous just because some other app mentions chrome in its name. | |
| local matches | |
| matches=$(printf '%s\n' "$raw" | awk -F'\t' -v w="$want" ' | |
| BEGIN { lw = tolower(w) } | |
| { | |
| id = $1; name = $2 | |
| lid = tolower(id); lname = tolower(name) | |
| if (lname == lw || lid == lw) { print "1\t" id "\t" name; next } | |
| if (index(lname, lw) == 1) { print "2\t" id "\t" name; next } | |
| if (index(lname, lw) || index(lid, lw)) { print "3\t" id "\t" name } | |
| }' | sort -n) | |
| if [ -z "$matches" ]; then | |
| warn "no app matching '$want' has file sharing enabled on this device." | |
| echo >&2 | |
| echo " Available:" >&2 | |
| list_sharing_apps >&2 || echo " (none)" >&2 | |
| die "pick one of the above, or pass a full bundle ID" | |
| fi | |
| local tier rows count | |
| tier=$(printf '%s\n' "$matches" | head -1 | cut -f1) | |
| rows=$(printf '%s\n' "$matches" | awk -F'\t' -v t="$tier" '$1 == t') | |
| count=$(printf '%s\n' "$rows" | wc -l) | |
| if [ "$count" -gt 1 ]; then | |
| warn "'$want' matches more than one app:" | |
| printf '%s\n' "$rows" | awk -F'\t' '{ printf " %-30s %s\n", $2, $3 }' >&2 | |
| die "be more specific, or pass the full bundle ID" | |
| fi | |
| BUNDLE=$(printf '%s\n' "$rows" | cut -f2) | |
| } | |
| # Translate house_arrest's two opaque failures into something actionable. | |
| # Called once before any subcommand that talks to an app's container. | |
| # | |
| # ApplicationLookupFailed the bundle id matches no installed app | |
| # InstallationLookupFailed the app is installed but has no UIFileSharingEnabled | |
| # | |
| # These are genuinely distinct: a made-up bundle id yields the first, while | |
| # Safari (installed, no file sharing) yields the second. | |
| check_bundle_access() { | |
| local out | |
| out=$(afc info Documents 2>&1) || true | |
| case "$out" in | |
| *ApplicationLookupFailed*) ;; | |
| *InstallationLookupFailed*) ;; | |
| *) return 0 ;; | |
| esac | |
| local dev; dev=$(info_key -k DeviceName 2>/dev/null || true) | |
| [ -n "$dev" ] || dev="this device" | |
| case "$out" in | |
| *ApplicationLookupFailed*) | |
| warn "app '$BUNDLE' is not installed on $dev." ;; | |
| *) | |
| warn "app '$BUNDLE' is installed on $dev but does not enable file" | |
| warn "sharing (UIFileSharingEnabled), so its files are not reachable over USB." ;; | |
| esac | |
| local apps | |
| if apps=$(list_sharing_apps); then | |
| echo >&2 | |
| echo " Apps on $dev with file sharing enabled:" >&2 | |
| printf '%s\n' "$apps" >&2 | |
| elif ! command -v ifuse >/dev/null 2>&1; then | |
| echo >&2 | |
| echo " Install ifuse to list this device's file-sharing apps:" >&2 | |
| echo " $(install_hint ifuse)" >&2 | |
| else | |
| echo >&2 | |
| echo " No app on $dev has file sharing enabled." >&2 | |
| fi | |
| cat >&2 <<EOF | |
| Select one with: ipad-util -a <bundle-id> <subcommand> | |
| Or set BUNDLE=<bundle-id> in $CONFIG_FILE | |
| Note: a folder you created yourself at the top of "On My iPhone" / | |
| "On My iPad" in the Files app is NOT reachable over USB — that storage | |
| belongs to the Files app and iOS does not share it. Only the folders | |
| listed above are. Move your files into one of them first. | |
| EOF | |
| exit 1 | |
| } | |
| mountpoint_default() { | |
| if [ -n "$DEFAULT_MOUNT_BASE" ]; then | |
| echo "$DEFAULT_MOUNT_BASE" | |
| else | |
| echo "/tmp/ipad-${BUNDLE##*.}" | |
| fi | |
| } | |
| human_bytes() { | |
| local n="${1:-0}" | |
| # Apple reports storage in decimal (GB = 10^9), so use SI base to match what | |
| # the user sees in Settings. Binary "GiB" output would be confusing here. | |
| if command -v numfmt >/dev/null 2>&1; then | |
| numfmt --to=si --suffix=B --format=%.2f "$n" | |
| else | |
| echo "${n} B" | |
| fi | |
| } | |
| # afcclient exits 0 even when a transfer fails — it prints "Error: ..." and | |
| # returns success — so the only reliable signal is its output. Runs an afc | |
| # command, and on failure echoes the error and returns 1. | |
| # Symlinks inside an app's Documents are a common trigger: AFC refuses to open | |
| # them ("Permission denied"), which would otherwise be counted as transferred. | |
| afc_ok() { | |
| local out | |
| out=$(afc "$@" 2>&1) || true | |
| case "$out" in | |
| *Error:*|*ERROR:*) printf '%s\n' "$out" >&2; return 1 ;; | |
| esac | |
| return 0 | |
| } | |
| # Record a failed transfer so the run can report it and exit non-zero. | |
| tx_fail() { | |
| local idx="$1" name="$2" msg="$3" | |
| _TX_STATUS[$idx]=3 | |
| _TX_FAIL_NAMES+=("$name: $msg") | |
| } | |
| # Print any failures collected during a transfer. Returns 1 if there were any. | |
| tx_report_failures() { | |
| [ "${#_TX_FAIL_NAMES[@]}" -gt 0 ] || return 0 | |
| echo >&2 | |
| warn "${#_TX_FAIL_NAMES[@]} file(s) FAILED to transfer:" | |
| local f | |
| for f in "${_TX_FAIL_NAMES[@]}"; do echo " $f" >&2; done | |
| return 1 | |
| } | |
| # Extract one st_* field from `afc info`. Two output formats are in the wild: | |
| # | |
| # afcclient < 1.4 (plain text) afcclient >= 1.4 (JSON) | |
| # st_size: 8706 { | |
| # st_ifmt: S_IFREG "st_size": 8706, | |
| # "st_ifmt": "S_IFREG", | |
| # } | |
| # | |
| # Both are handled here so the script works across libimobiledevice versions. | |
| # `info` exits 0 even when the path doesn't exist — it just prints an "Error:" | |
| # line — so its exit status can't be trusted; we return 1 on that instead. | |
| afc_stat_field() { | |
| afc info "$1" 2>/dev/null | awk -v f="$2" ' | |
| /^[ \t]*(Error|ERROR)/ { exit 1 } | |
| { line=$0; gsub(/[",]/,"",line); sub(/^[ \t]+/,"",line) | |
| n=index(line,":"); if (n==0) next | |
| key=substr(line,1,n-1); val=substr(line,n+1) | |
| gsub(/^[ \t]+|[ \t]+$/,"",val) | |
| if (key==f) { print val; found=1; exit } } | |
| END { if (!found) exit 1 }' | |
| } | |
| # Empty output + zero status when absent: callers assign this bare under set -e. | |
| afc_size() { afc_stat_field "$1" st_size || true; } | |
| afc_is_dir() { [ "$(afc_stat_field "$1" st_ifmt)" = "S_IFDIR" ]; } | |
| afc_exists() { afc_stat_field "$1" st_ifmt >/dev/null 2>&1; } | |
| # Create a remote directory and any missing parents. afcclient mkdir is not recursive. | |
| afc_mkdir_p() { | |
| local path="$1" | |
| [ -z "$path" ] && return | |
| [ "$path" = "." ] && return | |
| [ "$path" = "/" ] && return | |
| afc_exists "$path" && return | |
| afc_mkdir_p "$(dirname "$path")" | |
| afc mkdir "$path" >/dev/null 2>&1 || true | |
| } | |
| # Single-file put. With multi-line progress on, the file's status moves through | |
| # pending → active → done and the whole frame is redrawn each poll. | |
| afc_put_one() { | |
| local src="$1" dest="$2" idx="$3" | |
| local fsz; fsz=$(stat -c %s "$src" 2>/dev/null || echo 0) | |
| _TX_FILE_IDX=$((_TX_FILE_IDX + 1)) | |
| _TX_STATUS[$idx]=1 | |
| if [ "$_TX_PROGRESS" != "1" ]; then | |
| # Plain fallback for non-tty / -q. | |
| local name; name=$(basename "$src") | |
| if [ "$_TX_FILE_COUNT" -gt 1 ]; then | |
| printf " + %s (%d/%d)\n" "$name" "$_TX_FILE_IDX" "$_TX_FILE_COUNT" | |
| else | |
| printf " + %s\n" "$name" | |
| fi | |
| if afc_ok put "$src" "$dest"; then | |
| _TX_STATUS[$idx]=2 | |
| else | |
| tx_fail "$idx" "$(basename "$src")" "put failed" | |
| fi | |
| _TX_DONE=$((_TX_DONE + fsz)) | |
| return | |
| fi | |
| local errf; errf=$(mktemp) | |
| afc put "$src" "$dest" >"$errf" 2>&1 & | |
| local pid=$! | |
| trap 'kill '"$pid"' 2>/dev/null; printf "\n"; exit 130' INT | |
| # Render once immediately so the active spinner appears without waiting 250ms. | |
| local now0; now0=$(date +%s) | |
| local elapsed0=$((now0 - _TX_START)) | |
| local rate0=0 | |
| [ "$elapsed0" -gt 0 ] && rate0=$((_TX_DONE / elapsed0)) | |
| render_frame "$_TX_DONE" "$_TX_TOTAL" "$rate0" | |
| while kill -0 "$pid" 2>/dev/null; do | |
| sleep 0.25 | |
| local cur; cur=$(afc_size "$dest" 2>/dev/null) | |
| [ -z "$cur" ] && cur=0 | |
| local now; now=$(date +%s) | |
| local elapsed=$((now - _TX_START)) | |
| local current_done=$((_TX_DONE + cur)) | |
| local rate=0 | |
| [ "$elapsed" -gt 0 ] && rate=$((current_done / elapsed)) | |
| render_frame "$current_done" "$_TX_TOTAL" "$rate" | |
| done | |
| wait "$pid" | |
| local rc=$? | |
| trap - INT | |
| _TX_DONE=$((_TX_DONE + fsz)) | |
| if grep -qE '^[ \t]*(Error|ERROR)' "$errf" 2>/dev/null; then | |
| tx_fail "$idx" "$(basename "$src")" "$(grep -m1 -E '^[ \t]*(Error|ERROR)' "$errf")" | |
| else | |
| _TX_STATUS[$idx]=2 | |
| fi | |
| rm -f "$errf" | |
| # Final frame for this file: status flips to ✓, bar reflects completed bytes. | |
| local now2; now2=$(date +%s) | |
| local elapsed2=$((now2 - _TX_START)) | |
| local rate2=0 | |
| [ "$elapsed2" -gt 0 ] && rate2=$((_TX_DONE / elapsed2)) | |
| render_frame "$_TX_DONE" "$_TX_TOTAL" "$rate2" | |
| return "$rc" | |
| } | |
| # Single-file get. Symmetric to afc_put_one. | |
| afc_get_one() { | |
| local remote="$1" local_path="$2" idx="$3" | |
| local rsz; rsz=$(afc_size "$remote" 2>/dev/null) | |
| [ -z "$rsz" ] && rsz=0 | |
| _TX_FILE_IDX=$((_TX_FILE_IDX + 1)) | |
| _TX_STATUS[$idx]=1 | |
| if [ "$_TX_PROGRESS" != "1" ]; then | |
| local name; name=$(basename "$remote") | |
| if [ "$_TX_FILE_COUNT" -gt 1 ]; then | |
| printf " > %s (%d/%d)\n" "$name" "$_TX_FILE_IDX" "$_TX_FILE_COUNT" | |
| else | |
| printf " > %s\n" "$name" | |
| fi | |
| if afc_ok get "$remote" "$local_path"; then | |
| _TX_STATUS[$idx]=2 | |
| else | |
| tx_fail "$idx" "$(basename "$remote")" "get failed" | |
| fi | |
| _TX_DONE=$((_TX_DONE + rsz)) | |
| return | |
| fi | |
| local errf; errf=$(mktemp) | |
| afc get "$remote" "$local_path" >"$errf" 2>&1 & | |
| local pid=$! | |
| trap 'kill '"$pid"' 2>/dev/null; printf "\n"; exit 130' INT | |
| local now0; now0=$(date +%s) | |
| local elapsed0=$((now0 - _TX_START)) | |
| local rate0=0 | |
| [ "$elapsed0" -gt 0 ] && rate0=$((_TX_DONE / elapsed0)) | |
| render_frame "$_TX_DONE" "$_TX_TOTAL" "$rate0" | |
| while kill -0 "$pid" 2>/dev/null; do | |
| sleep 0.25 | |
| local cur=0 | |
| [ -f "$local_path" ] && cur=$(stat -c %s "$local_path" 2>/dev/null || echo 0) | |
| local now; now=$(date +%s) | |
| local elapsed=$((now - _TX_START)) | |
| local current_done=$((_TX_DONE + cur)) | |
| local rate=0 | |
| [ "$elapsed" -gt 0 ] && rate=$((current_done / elapsed)) | |
| render_frame "$current_done" "$_TX_TOTAL" "$rate" | |
| done | |
| wait "$pid" | |
| local rc=$? | |
| trap - INT | |
| _TX_DONE=$((_TX_DONE + rsz)) | |
| if grep -qE '^[ \t]*(Error|ERROR)' "$errf" 2>/dev/null; then | |
| tx_fail "$idx" "$(basename "$remote")" "$(grep -m1 -E '^[ \t]*(Error|ERROR)' "$errf")" | |
| else | |
| _TX_STATUS[$idx]=2 | |
| fi | |
| rm -f "$errf" | |
| local now2; now2=$(date +%s) | |
| local elapsed2=$((now2 - _TX_START)) | |
| local rate2=0 | |
| [ "$elapsed2" -gt 0 ] && rate2=$((_TX_DONE / elapsed2)) | |
| render_frame "$_TX_DONE" "$_TX_TOTAL" "$rate2" | |
| return "$rc" | |
| } | |
| # Walk a local path, populating these arrays for cmd_put to drive the transfer: | |
| # _PLAN_SRCS, _PLAN_DESTS, _PLAN_DIRS (parallel-indexed; dirs are remote-only) | |
| # Display names go into _TX_NAMES, sizes into _TX_SIZES. | |
| _plan_local() { | |
| local arg="$1" arg_name; arg_name=$(basename "$arg") | |
| if [ -f "$arg" ]; then | |
| _PLAN_SRCS+=("$arg") | |
| _PLAN_DESTS+=("Documents/$arg_name") | |
| _TX_NAMES+=("$arg_name") | |
| _TX_SIZES+=("$(stat -c %s "$arg")") | |
| return | |
| fi | |
| [ -d "$arg" ] || return | |
| _PLAN_DIRS+=("Documents/$arg_name") | |
| while IFS= read -r -d '' f; do | |
| local rel="${f#$arg/}" | |
| if [ -d "$f" ]; then | |
| _PLAN_DIRS+=("Documents/$arg_name/$rel") | |
| else | |
| _PLAN_SRCS+=("$f") | |
| _PLAN_DESTS+=("Documents/$arg_name/$rel") | |
| _TX_NAMES+=("$arg_name/$rel") | |
| _TX_SIZES+=("$(stat -c %s "$f")") | |
| fi | |
| done < <(find "$arg" -mindepth 1 -print0) | |
| } | |
| # Walk a remote path, populating arrays for cmd_get: | |
| # _PLAN_REMOTES, _PLAN_LOCALS (parallel-indexed) | |
| # Display names go into _TX_NAMES, sizes into _TX_SIZES. | |
| _plan_remote() { | |
| local remote="$1" local_base="$2" prefix="$3" | |
| if afc_is_dir "$remote"; then | |
| local entry | |
| while IFS= read -r entry; do | |
| [ -z "$entry" ] && continue | |
| _plan_remote "$remote/$entry" "$local_base/$entry" "$prefix$entry/" | |
| done < <(afc ls "$remote" 2>/dev/null) | |
| else | |
| local sz; sz=$(afc_size "$remote") | |
| [ -z "$sz" ] && sz=0 | |
| _PLAN_REMOTES+=("$remote") | |
| _PLAN_LOCALS+=("$local_base") | |
| _TX_NAMES+=("$prefix$(basename "$remote")") | |
| _TX_SIZES+=("$sz") | |
| fi | |
| } | |
| # Recursively delete a remote tree. afcclient rm is not recursive. | |
| rm_tree() { | |
| local path="$1" | |
| if afc_is_dir "$path"; then | |
| local entry | |
| while IFS= read -r entry; do | |
| [ -z "$entry" ] && continue | |
| rm_tree "$path/$entry" | |
| done < <(afc ls "$path" 2>/dev/null) | |
| fi | |
| afc rm "$path" >/dev/null 2>&1 || true | |
| } | |
| confirm() { | |
| local prompt="$1" reply | |
| if [ "${ASSUME_YES:-0}" = "1" ]; then return 0; fi | |
| read -r -p "$prompt [y/N] " reply | |
| [[ "$reply" =~ ^[Yy]$ ]] | |
| } | |
| # Sum bytes of a local file or directory tree. | |
| local_size() { | |
| local path="$1" | |
| if [ -d "$path" ]; then | |
| find "$path" -type f -printf '%s\n' 2>/dev/null | awk '{s+=$1} END {print s+0}' | |
| elif [ -f "$path" ]; then | |
| stat -c %s "$path" | |
| else | |
| echo 0 | |
| fi | |
| } | |
| # Sum bytes of a remote file or directory tree (one afc info per entry — slow on big trees). | |
| remote_size() { | |
| local path="$1" | |
| if afc_is_dir "$path"; then | |
| local entry sum=0 sz | |
| while IFS= read -r entry; do | |
| [ -z "$entry" ] && continue | |
| sz=$(remote_size "$path/$entry") | |
| sum=$((sum + sz)) | |
| done < <(afc ls "$path" 2>/dev/null) | |
| echo "$sum" | |
| else | |
| afc_size "$path" | |
| fi | |
| } | |
| # Currently-free bytes on the iOS device (matches what iPad Settings shows as free). | |
| device_free() { | |
| info_key -q com.apple.disk_usage -k AmountDataAvailable | |
| } | |
| # Free bytes on the local filesystem containing PATH. | |
| path_free() { | |
| local path="$1" | |
| [ -e "$path" ] || path=$(dirname "$path") | |
| df --output=avail -B 1 "$path" 2>/dev/null | awk 'NR==2 {gsub(/ /,""); print}' | |
| } | |
| # Pre-flight size check. Args: direction (put|get), transfer bytes, destination free bytes. | |
| # Prompts on shortfall; bails out if the user says no. | |
| preflight_size() { | |
| local direction="$1" tx="$2" dst_free="$3" | |
| [ "${tx:-0}" -gt 0 ] || return 0 | |
| local destlabel="device" | |
| [ "$direction" = "get" ] && destlabel="local destination" | |
| if [ -z "$dst_free" ] || [ "$dst_free" -le 0 ]; then | |
| warn "could not determine $destlabel free space — skipping size check" | |
| return 0 | |
| fi | |
| printf "Transfer size: %s %s free: %s\n" \ | |
| "$(human_bytes "$tx")" "$destlabel" "$(human_bytes "$dst_free")" | |
| if [ "$tx" -gt "$dst_free" ]; then | |
| local shortfall=$((tx - dst_free)) | |
| warn "$(human_bytes "$shortfall") larger than free space on $destlabel" | |
| if [ "${ASSUME_YES:-0}" = "1" ]; then | |
| warn "proceeding anyway due to -y" | |
| return 0 | |
| fi | |
| confirm "Proceed anyway?" || die "transfer aborted" | |
| fi | |
| } | |
| # --- subcommands -------------------------------------------------------------- | |
| cmd_put() { | |
| [ "$#" -gt 0 ] || die "put: need at least one path" | |
| # Single planning pass: collect every src/dest pair and remote dirs to create. | |
| _PLAN_SRCS=(); _PLAN_DESTS=(); _PLAN_DIRS=() | |
| _TX_NAMES=(); _TX_SIZES=() | |
| local arg | |
| for arg in "$@"; do | |
| if [ ! -e "$arg" ]; then | |
| warn "'$arg' not found — skipping" | |
| continue | |
| fi | |
| _plan_local "$arg" | |
| done | |
| [ "${#_PLAN_SRCS[@]}" -gt 0 ] || die "nothing to transfer" | |
| local total=0 sz | |
| for sz in "${_TX_SIZES[@]}"; do total=$((total + sz)); done | |
| preflight_size put "$total" "$(device_free)" | |
| # Print intro lines (one per top-level arg). | |
| for arg in "$@"; do | |
| [ -e "$arg" ] || continue | |
| local n; n=$(basename "$arg") | |
| if [ -d "$arg" ]; then | |
| echo ">> [dir] $arg/ -> Documents/$n/" | |
| else | |
| echo ">> [file] $arg -> Documents/$n" | |
| fi | |
| done | |
| # Create all remote directories up front. | |
| local d | |
| for d in "${_PLAN_DIRS[@]}"; do afc_mkdir_p "$d"; done | |
| tx_init "$total" | |
| local i | |
| for ((i=0; i<${#_PLAN_SRCS[@]}; i++)); do | |
| afc_put_one "${_PLAN_SRCS[$i]}" "${_PLAN_DESTS[$i]}" "$i" | |
| done | |
| render_done | |
| tx_report_failures | |
| } | |
| cmd_get() { | |
| [ "$#" -ge 1 ] || die "get: need remote path" | |
| local remote="$1" local_path="${2:-$(basename "$1")}" | |
| afc_exists "$remote" || die "get: remote path '$remote' not found" | |
| # `get <file> <existing-dir>` means "put it in there". Name the file | |
| # explicitly so the progress poll can stat the real output path. | |
| if [ -d "$local_path" ] && ! afc_is_dir "$remote"; then | |
| local_path="${local_path%/}/$(basename "$remote")" | |
| fi | |
| echo " measuring remote tree..." | |
| _PLAN_REMOTES=(); _PLAN_LOCALS=() | |
| _TX_NAMES=(); _TX_SIZES=() | |
| if afc_is_dir "$remote"; then | |
| _plan_remote "$remote" "$local_path" "" | |
| else | |
| local sz; sz=$(afc_size "$remote") | |
| [ -z "$sz" ] && sz=0 | |
| _PLAN_REMOTES+=("$remote") | |
| _PLAN_LOCALS+=("$local_path") | |
| _TX_NAMES+=("$(basename "$remote")") | |
| _TX_SIZES+=("$sz") | |
| fi | |
| [ "${#_PLAN_REMOTES[@]}" -gt 0 ] || die "nothing to transfer" | |
| local total=0 s | |
| for s in "${_TX_SIZES[@]}"; do total=$((total + s)); done | |
| preflight_size get "$total" "$(path_free "$local_path")" | |
| if afc_is_dir "$remote"; then | |
| echo ">> [dir] $remote/ -> $local_path/" | |
| else | |
| echo ">> [file] $remote -> $local_path" | |
| fi | |
| # Make sure every local destination directory exists. | |
| local i | |
| for ((i=0; i<${#_PLAN_LOCALS[@]}; i++)); do | |
| mkdir -p "$(dirname "${_PLAN_LOCALS[$i]}")" | |
| done | |
| tx_init "$total" | |
| for ((i=0; i<${#_PLAN_REMOTES[@]}; i++)); do | |
| afc_get_one "${_PLAN_REMOTES[$i]}" "${_PLAN_LOCALS[$i]}" "$i" | |
| done | |
| render_done | |
| tx_report_failures | |
| } | |
| cmd_ls() { | |
| local target="${1:-Documents}" | |
| afc ls "$target" | |
| } | |
| cmd_rm() { | |
| local force=0 | |
| if [ "${1:-}" = "-f" ]; then force=1; shift; fi | |
| [ "$#" -gt 0 ] || die "rm: need at least one remote path" | |
| for path in "$@"; do | |
| if ! afc_exists "$path"; then | |
| warn "'$path' not found — skipping" | |
| continue | |
| fi | |
| local kind="file" | |
| afc_is_dir "$path" && kind="dir" | |
| if [ "$force" -eq 0 ]; then | |
| confirm "Delete $kind '$path'?" || { echo " skipped"; continue; } | |
| fi | |
| echo ">> rm $kind $path" | |
| if [ "$kind" = "dir" ]; then | |
| rm_tree "$path" | |
| else | |
| afc rm "$path" | |
| fi | |
| done | |
| } | |
| cmd_mv() { | |
| [ "$#" -eq 2 ] || die "mv: need <old> <new>" | |
| afc mv "$1" "$2" | |
| echo "renamed: $1 -> $2" | |
| } | |
| cmd_mkdir() { | |
| [ "$#" -eq 1 ] || die "mkdir: need <path>" | |
| afc_mkdir_p "$1" | |
| echo "created: $1" | |
| } | |
| cmd_sync() { | |
| [ "$#" -eq 1 ] || die "sync: need one local directory" | |
| local src="$1" | |
| [ -d "$src" ] || die "sync: '$src' is not a directory" | |
| local base; base=$(basename "$src") | |
| local remote_dir="Documents/$base" | |
| afc_mkdir_p "$remote_dir" | |
| echo " planning sync (sizing changed files)..." | |
| _PLAN_SRCS=(); _PLAN_DESTS=() | |
| _TX_NAMES=(); _TX_SIZES=() | |
| local plan_total=0 skipped=0 | |
| while IFS= read -r -d '' f; do | |
| local rel="${f#$src/}" | |
| local rp="$remote_dir/$rel" | |
| local lsz rsz | |
| lsz=$(stat -c %s "$f") | |
| rsz=$(afc_size "$rp" || true) | |
| if [ -n "$rsz" ] && [ "$rsz" = "$lsz" ]; then | |
| skipped=$((skipped + 1)) | |
| continue | |
| fi | |
| _PLAN_SRCS+=("$f") | |
| _PLAN_DESTS+=("$rp") | |
| _TX_NAMES+=("$rel") | |
| _TX_SIZES+=("$lsz") | |
| plan_total=$((plan_total + lsz)) | |
| done < <(find "$src" -type f -print0) | |
| local plan_count=${#_PLAN_SRCS[@]} | |
| echo " $plan_count file(s) need transfer ($skipped already up-to-date)" | |
| if [ "$plan_count" -eq 0 ]; then | |
| echo "sync done: nothing to do" | |
| return 0 | |
| fi | |
| preflight_size put "$plan_total" "$(device_free)" | |
| # Ensure all parent dirs exist on the device. | |
| local i | |
| for ((i=0; i<plan_count; i++)); do | |
| afc_mkdir_p "$(dirname "${_PLAN_DESTS[$i]}")" | |
| done | |
| tx_init "$plan_total" | |
| for ((i=0; i<plan_count; i++)); do | |
| afc_put_one "${_PLAN_SRCS[$i]}" "${_PLAN_DESTS[$i]}" "$i" | |
| done | |
| render_done | |
| echo "sync done: $plan_count pushed, $skipped already up-to-date" | |
| tx_report_failures | |
| } | |
| cmd_info() { | |
| need ideviceinfo | |
| local udid; udid=$(resolve_udid) | |
| local name class type version build total avail batt charging | |
| name=$(info_key -k DeviceName) | |
| class=$(info_key -k DeviceClass) | |
| type=$(info_key -k ProductType) | |
| version=$(info_key -k ProductVersion) | |
| build=$(info_key -k BuildVersion) | |
| total=$(info_key -q com.apple.disk_usage -k TotalDiskCapacity) | |
| # AmountDataAvailable is the actually-free space; TotalDataAvailable counts | |
| # purgeable caches as free, which doesn't match what iPad Settings shows. | |
| avail=$(info_key -q com.apple.disk_usage -k AmountDataAvailable) | |
| batt=$(info_key -q com.apple.mobile.battery -k BatteryCurrentCapacity 2>/dev/null || true) | |
| charging=$(info_key -q com.apple.mobile.battery -k BatteryIsCharging 2>/dev/null || true) | |
| echo "Name: $name" | |
| echo "Model: $class ($type)" | |
| echo "iOS: $version ($build)" | |
| echo "UDID: $udid" | |
| [ -n "$total" ] && echo "Storage: $(human_bytes "$avail") free of $(human_bytes "$total")" | |
| if [ -n "$batt" ]; then | |
| local cstr="" | |
| [ "$charging" = "true" ] && cstr=" (charging)" | |
| echo "Battery: ${batt}%${cstr}" | |
| fi | |
| echo "App: $BUNDLE" | |
| } | |
| cmd_space() { | |
| need ideviceinfo | |
| # Keys (in com.apple.disk_usage): | |
| # TotalDiskCapacity full capacity (matches "256 GB" on the device spec) | |
| # TotalDataCapacity storage available to user data (excludes iOS system reservation) | |
| # AmountDataAvailable actually free right now (matches what Settings shows) | |
| # TotalDataAvailable free + purgeable caches iOS can evict | |
| local total data_cap free purgeable_free | |
| total=$(info_key -q com.apple.disk_usage -k TotalDiskCapacity) | |
| data_cap=$(info_key -q com.apple.disk_usage -k TotalDataCapacity) | |
| free=$(info_key -q com.apple.disk_usage -k AmountDataAvailable) | |
| purgeable_free=$(info_key -q com.apple.disk_usage -k TotalDataAvailable) | |
| [ -n "$total" ] && [ -n "$data_cap" ] && [ -n "$free" ] || die "could not read storage info" | |
| local user_used=$((data_cap - free)) | |
| local system=$((total - data_cap)) | |
| local purgeable=$((purgeable_free - free)) | |
| local pct=$(( user_used * 100 / total )) | |
| printf "used: %s (your data)\n" "$(human_bytes "$user_used")" | |
| printf "free: %s (immediately available)\n" "$(human_bytes "$free")" | |
| printf "system: %s (iOS itself)\n" "$(human_bytes "$system")" | |
| printf "total: %s\n" "$(human_bytes "$total")" | |
| printf "%d%% used by your data\n" "$pct" | |
| if [ "$purgeable" -gt 0 ]; then | |
| printf "(plus %s of purgeable cache that iOS will evict if needed)\n" \ | |
| "$(human_bytes "$purgeable")" | |
| fi | |
| } | |
| cmd_battery() { | |
| need ideviceinfo | |
| local batt charging | |
| batt=$(info_key -q com.apple.mobile.battery -k BatteryCurrentCapacity) | |
| charging=$(info_key -q com.apple.mobile.battery -k BatteryIsCharging) | |
| [ -n "$batt" ] || die "could not read battery info" | |
| local cstr="" | |
| [ "$charging" = "true" ] && cstr=" (charging)" | |
| echo "${batt}%${cstr}" | |
| } | |
| cmd_apps() { | |
| # Only apps with UIFileSharingEnabled are usable here, and ifuse reports | |
| # exactly those — no ideviceinstaller needed (it isn't packaged on Fedora). | |
| local apps | |
| if apps=$(list_sharing_apps); then | |
| echo "Apps on this device with file sharing enabled:" | |
| echo | |
| printf '%s\n' "$apps" | |
| echo | |
| echo "Use one by short name (case-insensitive), or by full bundle ID:" | |
| echo " ipad-util -a chrome ls" | |
| echo " ipad-util -a com.google.chrome.ios ls" | |
| echo | |
| echo "Set a default with BUNDLE=<name-or-bundle-id> in $CONFIG_FILE" | |
| return | |
| fi | |
| if ! command -v ifuse >/dev/null 2>&1; then | |
| warn "ifuse is not installed — cannot list this device's file-sharing apps." | |
| echo " install: $(install_hint ifuse)" >&2 | |
| if command -v ideviceinstaller >/dev/null 2>&1; then | |
| echo >&2 | |
| echo "Falling back to ideviceinstaller (lists all apps, not just file-sharing ones):" >&2 | |
| local udid; udid=$(resolve_udid) | |
| ideviceinstaller -u "$udid" list -o list_user 2>/dev/null \ | |
| || ideviceinstaller -u "$udid" -l -o list_user | |
| fi | |
| exit 1 | |
| fi | |
| echo "No app on this device has file sharing enabled." | |
| echo | |
| echo "Install one that does (VLC, Infuse, Kodi, Documents by Readdle, ...)," | |
| echo "or copy files into an existing app's folder from the Files app." | |
| } | |
| cmd_screenshot() { | |
| need idevicescreenshot | |
| local out="${1:-screenshot-$(date +%Y%m%d-%H%M%S).png}" | |
| idevicescreenshot -u "$(resolve_udid)" "$out" | |
| echo "saved: $out" | |
| } | |
| cmd_mount() { | |
| need ifuse | |
| local mp="${1:-$(mountpoint_default)}" | |
| mkdir -p "$mp" | |
| if mountpoint -q "$mp" 2>/dev/null; then | |
| echo "already mounted at $mp" | |
| return 0 | |
| fi | |
| local udid; udid=$(resolve_udid) | |
| ifuse --documents "$BUNDLE" -u "$udid" "$mp" | |
| echo "mounted: $mp (bundle=$BUNDLE udid=$udid)" | |
| } | |
| cmd_unmount() { | |
| local mp="${1:-$(mountpoint_default)}" | |
| if ! mountpoint -q "$mp" 2>/dev/null; then | |
| echo "nothing mounted at $mp" | |
| return 0 | |
| fi | |
| fusermount -u "$mp" | |
| echo "unmounted: $mp" | |
| if [ "$mp" = "$(mountpoint_default)" ] && [ -d "$mp" ] && [ -z "$(ls -A "$mp" 2>/dev/null)" ]; then | |
| rmdir "$mp" 2>/dev/null || true | |
| fi | |
| } | |
| cmd_doctor() { | |
| local ok=0 | |
| local pm; pm=$(_pm) | |
| echo "Detected package manager: ${pm:-unknown}" | |
| echo | |
| echo "Required tools:" | |
| _doctor_check afcclient "file transfer (put/get/ls/rm/...)" 1 || ok=1 | |
| _doctor_check idevice_id "device detection" 1 || ok=1 | |
| echo | |
| echo "Optional tools:" | |
| _doctor_check ideviceinfo "info, space, battery" | |
| _doctor_check idevicescreenshot "screenshot" | |
| _doctor_check ifuse "mount / unmount, apps (list file-sharing apps)" | |
| _doctor_check fusermount "unmount" | |
| _doctor_check numfmt "human-readable sizes" | |
| echo | |
| echo "Connectivity:" | |
| if command -v idevice_id >/dev/null 2>&1; then | |
| local udids | |
| mapfile -t udids < <(idevice_id -l 2>/dev/null) | |
| if [ "${#udids[@]}" -eq 0 ]; then | |
| echo " no device detected — plug in and tap 'Trust' on the iPad" | |
| else | |
| local u | |
| for u in "${udids[@]}"; do echo " device: $u"; done | |
| fi | |
| else | |
| echo " (cannot check — idevice_id missing)" | |
| fi | |
| echo | |
| if [ "$ok" = "0" ]; then | |
| echo "Status: ready" | |
| else | |
| echo "Status: missing required tools — see above" | |
| exit 1 | |
| fi | |
| } | |
| cmd_status() { | |
| echo "app: $BUNDLE" | |
| local udids | |
| mapfile -t udids < <(idevice_id -l 2>/dev/null) | |
| if [ "${#udids[@]}" -eq 0 ]; then | |
| echo "device(s): (none detected)" | |
| else | |
| echo "device(s): ${udids[*]}" | |
| fi | |
| local mp; mp=$(mountpoint_default) | |
| if mountpoint -q "$mp" 2>/dev/null; then | |
| echo "mount: $mp (active)" | |
| else | |
| echo "mount: $mp (inactive)" | |
| fi | |
| mount | awk '/fuse\.ifuse/ {print " also mounted: " $3}' | |
| [ -f "$CONFIG_FILE" ] && echo "config: $CONFIG_FILE" | |
| } | |
| # Prints the header comment block. Exit 0 when the user asked for help, | |
| # 2 when they were misusing the tool. | |
| usage() { | |
| local rc="${1:-2}" | |
| if [ "$rc" = "0" ]; then | |
| sed -n '/^# ipad-util/,/^$/p' "$0" | sed -E 's/^# ?//' | |
| else | |
| sed -n '/^# ipad-util/,/^$/p' "$0" | sed -E 's/^# ?//' >&2 | |
| fi | |
| exit "$rc" | |
| } | |
| # --- global flag parsing ------------------------------------------------------ | |
| while [ "$#" -gt 0 ]; do | |
| case "$1" in | |
| -a|--app) BUNDLE="${2:-}"; [ "$#" -ge 2 ] || die "-a/--app needs a bundle ID or app name"; shift 2 ;; | |
| -u|--udid) UDID_OVERRIDE="$2"; shift 2 ;; | |
| -y|--yes) ASSUME_YES=1; shift ;; | |
| -q|--no-progress) NO_PROGRESS=1; shift ;; | |
| -h|--help|help) usage 0 ;; | |
| --) shift; break ;; | |
| -*) die "unknown flag: $1 (try --help)" ;; | |
| *) break ;; | |
| esac | |
| done | |
| [ "$#" -ge 1 ] || usage | |
| sub="$1"; shift | |
| # Preflight: required tools must exist for any real subcommand. `doctor` and | |
| # `help` are exempt so the user can still diagnose a broken install. | |
| case "$sub" in | |
| doctor|help|--help|-h) ;; | |
| *) | |
| command -v afcclient >/dev/null 2>&1 || die "missing required tool: afcclient (run 'ipad-util doctor' for details)" | |
| command -v idevice_id >/dev/null 2>&1 || die "missing required tool: idevice_id (run 'ipad-util doctor' for details)" | |
| ;; | |
| esac | |
| # Expand a short app name ("chrome") to its bundle ID, then fail early and | |
| # legibly if the app can't be opened — rather than letting afcclient's raw | |
| # "ApplicationLookupFailed" reach the user mid-transfer. | |
| case "$sub" in | |
| put|get|ls|rm|mv|mkdir|sync|mount|m) | |
| resolve_bundle | |
| check_bundle_access | |
| ;; | |
| esac | |
| case "$sub" in | |
| put) cmd_put "$@" ;; | |
| get) cmd_get "$@" ;; | |
| ls) cmd_ls "$@" ;; | |
| rm) cmd_rm "$@" ;; | |
| mv) cmd_mv "$@" ;; | |
| mkdir) cmd_mkdir "$@" ;; | |
| sync) cmd_sync "$@" ;; | |
| info) cmd_info ;; | |
| space|df) cmd_space ;; | |
| battery) cmd_battery ;; | |
| apps) cmd_apps ;; | |
| screenshot|shot) cmd_screenshot "$@" ;; | |
| mount|m) cmd_mount "$@" ;; | |
| unmount|umount|u) cmd_unmount "$@" ;; | |
| status) cmd_status ;; | |
| doctor) cmd_doctor ;; | |
| *) die "unknown subcommand: $sub (try: --help)" ;; | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment