Skip to content

Instantly share code, notes, and snippets.

@addohm
Last active August 8, 2026 07:05
Show Gist options
  • Select an option

  • Save addohm/4bcab541bd1e25fc04a52e25d486061e to your computer and use it in GitHub Desktop.

Select an option

Save addohm/4bcab541bd1e25fc04a52e25d486061e to your computer and use it in GitHub Desktop.
linux ipad utility
#!/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