Copies photos and videos off an iPhone over USB, without iTunes, Photos.app or iCloud. Files land in a flat directory, named by content hash, so re-running only copies what is new.
The tool walks /DCIM on the device over AFC, reads each file, and writes it to
the storage directory as:
<original stem>.<first 12 hex chars of sha256>.<original extension>
for example IMG_4861.08e41dcbf16f.HEIC.
The hash in the name is what makes the tool idempotent. A file is skipped if anything with the same hash and extension is already stored, so a second run copies nothing. It also means the archive can be checked without a separate manifest: re-hash any file and compare it against its own name.
Local mtime is set to the creation date reported by the device rather than the device mtime, so files sort by when the photo was taken.
- An iPhone connected over USB, unlocked and paired
- usbmuxd running (built into macOS,
usbmuxdpackage on Linux) - Python 3.10 or newer
- uv
uv run python ./ihateapple.py /path/to/storage
The storage directory must already exist. Progress goes to stderr:
SCANNING /DCIM...
SCANNED 10339 files, 32.3GB in 0:00:15
[ 1234/10339] 31.2% 10.1GB/32.3GB 38.4MB/s ETA 0:09:47 SKIPPING /store/IMG_3757.9ca8930323ac.HEIC
DONE 10339/10339 files, 1505 copied, 8834 skipped, 32.3GB read in 0:34:12
Ctrl-C finishes the file being copied and then stops. Pressing it a second time aborts immediately.
The device is enumerated before copying starts, which is what lets the progress line show a real percentage and ETA. This costs no extra AFC round trips, because the walk already stats every file; the cost is just paid up front instead of spread across the run.
Every file is read in full on every run, including files that end up skipped. The hash is the only thing that decides whether a file is already stored, and computing it requires the bytes. A run therefore takes roughly as long as reading the entire camera roll, regardless of how little is new.
Transport is pinned to USB. usbmuxd also reports Wi-Fi-synced devices, and AFC over Wi-Fi measured about 9x slower on the same files.
Writes are atomic. Each file is written to a temporary name in the destination directory and renamed into place, so an interrupted run never leaves a partial file under a name that claims a given hash.
./ruff.sh
./mypy.sh