Replace gphoto2 with pymobiledevice3 for iOS connectivity

gphoto2 stopped working on iOS 17+ (Apple tightened PTP access).
New approach uses pymobiledevice3 via AFC protocol — the same protocol
Finder uses to browse the iPhone.

- photophetch-ios-helper.py: bundled Python script (in JAR resources)
  that calls pymobiledevice3 AFC to list-devices, list-photos, pull, delete
- IosConnector: rewritten to shell out to the helper script instead of
  gphoto2. Extracts the script from JAR to a temp file on first use.
- AppConfig: gphoto2Path → python3Path
- AppState: IosConnector now receives python3Path

Prerequisites: brew install pipx && pipx install pymobiledevice3
This commit is contained in:
Kyle Bolen
2026-07-28 07:47:16 +00:00
parent 66378b4a02
commit 50d6f66116
4 changed files with 281 additions and 181 deletions

View File

@@ -4,6 +4,16 @@ import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.DeviceType
import com.bolenpad.photophetch.model.MediaType
import com.bolenpad.photophetch.model.PhonePhoto
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import org.slf4j.LoggerFactory
import java.nio.file.Path
import java.time.Instant
@@ -13,215 +23,167 @@ import java.time.format.DateTimeFormatter
import kotlin.io.path.absolutePathString
/**
* DeviceConnector implementation for iPhones via gphoto2.
* DeviceConnector implementation for iPhones via pymobiledevice3.
*
* Requires `gphoto2` on PATH (or override via [gphoto2Bin]).
* Install via: brew install gphoto2
* Requires pymobiledevice3 installed via pipx:
* brew install pipx && pipx install pymobiledevice3
*
* Important macOS note: The built-in Image Capture daemon (PTPCamera) will
* conflict with gphoto2. If listing returns no files, run:
* killall PTPCamera
* PhotoPhetch shows instructions for this when no photos are found on a
* connected iPhone.
* Uses a bundled Python helper script (photophetch-ios-helper.py) that
* calls the pymobiledevice3 AFC service to list and pull files.
*
* Live Photo handling: gphoto2 lists both the .HEIC and the companion .MOV
* with matching base names. We detect the pair by comparing filenames and
* flag [PhonePhoto.isLivePhotoStill] / [PhonePhoto.isLivePhotoMotion].
* The MOV companion is excluded from import by default.
* Replaces the previous gphoto2-based implementation which stopped working
* on iOS 17+ due to Apple tightening PTP access.
*/
class IosConnector(
private val gphoto2Bin: String = "gphoto2",
private val python3Bin: String = "python3",
) : DeviceConnector {
private val log = LoggerFactory.getLogger(IosConnector::class.java)
private val json = Json { ignoreUnknownKeys = true }
// gphoto2 date format in --list-files output
private val gphotoDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
/** Path to the bundled helper script, extracted to a temp location on first use */
private val helperScript: String by lazy { extractHelperScript() }
override suspend fun detectDevices(): List<DeviceInfo> {
// gphoto2 --auto-detect lists connected cameras/phones
val output = runGphoto2("--auto-detect")
/*
* Output format:
* Model Port
* ----------------------------------------------------------
* Apple iPhone usb:001,023
*/
val devices = mutableListOf<DeviceInfo>()
var parsingStarted = false
for (line in output.lines()) {
if (line.startsWith("---")) { parsingStarted = true; continue }
if (!parsingStarted || line.isBlank()) continue
return try {
val output = runHelper("list-devices")
if (output.isBlank() || output.startsWith("Error")) return emptyList()
// Split on 2+ spaces to separate model from port
val parts = line.trim().split(Regex("\\s{2,}"), limit = 2)
if (parts.size < 2) continue
val model = parts[0].trim()
val port = parts[1].trim()
if (model.contains("iPhone", ignoreCase = true) ||
model.contains("iPad", ignoreCase = true) ||
model.contains("Apple", ignoreCase = true)
) {
devices.add(
DeviceInfo(
displayName = model,
type = DeviceType.IOS,
gphotoPort = port,
)
val array = json.parseToJsonElement(output).jsonArray
array.map { element ->
val obj = element.jsonObject
DeviceInfo(
displayName = obj["name"]?.jsonPrimitive?.contentOrNull ?: "iPhone",
type = DeviceType.IOS,
gphotoPort = obj["udid"]?.jsonPrimitive?.contentOrNull,
)
}
} catch (e: Exception) {
log.warn("iOS device detection failed: ${e.message}")
emptyList()
}
return devices
}
override suspend fun listPhotos(device: DeviceInfo): List<PhonePhoto> {
val port = requirePort(device)
return try {
val output = runHelper("list-photos")
if (output.isBlank() || output.startsWith("Error")) return emptyList()
// gphoto2 --list-files gives index, name, size, date for each file in DCIM
val output = runGphoto2("--port", port, "--list-files")
val array = json.parseToJsonElement(output).jsonArray
val photos = array.mapNotNull { element ->
try {
val obj = element.jsonObject
val devicePath = obj["devicePath"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
val filename = obj["filename"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
val sizeBytes = obj["sizeBytes"]?.jsonPrimitive?.longOrNull ?: 0L
val dateIso = obj["dateIso"]?.jsonPrimitive?.contentOrNull
val sourceFolder = obj["sourceFolder"]?.jsonPrimitive?.contentOrNull ?: "DCIM"
val mediaTypeStr = obj["mediaType"]?.jsonPrimitive?.contentOrNull ?: "PHOTO"
/*
* Output format (each file):
* #1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01
* #2 IMG_0001.MOV 12344 KB 2026-07-15 14:22:01
* #3 IMG_0002.JPG 3021 KB 2026-07-15 14:23:45
*/
val rawPhotos = mutableListOf<PhonePhoto>()
for (line in output.lines()) {
if (!line.trimStart().startsWith("#")) continue
val photo = parseGphoto2FileLine(line) ?: continue
rawPhotos.add(photo)
val dateTaken = if (dateIso != null) {
try { Instant.parse(dateIso + "Z") } catch (_: Exception) {
try { LocalDateTime.parse(dateIso).atZone(ZoneId.systemDefault()).toInstant() }
catch (_: Exception) { Instant.EPOCH }
}
} else Instant.EPOCH
PhonePhoto(
devicePath = devicePath,
filename = filename,
sizeBytes = sizeBytes,
dateTaken = dateTaken,
exifMake = "Apple",
exifModel = device.displayName,
mediaType = if (mediaTypeStr == "VIDEO") MediaType.VIDEO else MediaType.PHOTO,
sourceFolder = sourceFolder,
)
} catch (e: Exception) {
log.debug("Failed to parse photo entry: ${e.message}")
null
}
}
// Tag Live Photo pairs (HEIC + MOV with same base name)
tagLivePhotoPairs(photos).sortedByDescending { it.dateTaken }
} catch (e: Exception) {
log.error("iOS photo listing failed: ${e.message}")
emptyList()
}
if (rawPhotos.isEmpty()) {
// gphoto2 connected but found no files. Common causes:
// 1. iPhone screen is locked — unlock and retry
// 2. Another USB device (e.g. Android phone in Charging mode) is blocking
// USB enumeration — unplug other devices or switch Android to File Transfer mode
// 3. PTPCamera daemon grabbed the device — run: killall PTPCamera
// We return empty and let the UI show the no-photos state with a hint.
}
// Detect Live Photo pairs: HEIC + MOV with the same base name and close timestamps
return tagLivePhotoPairs(rawPhotos)
}
override suspend fun pullPhoto(device: DeviceInfo, photo: PhonePhoto, destinationDir: Path): Path {
val port = requirePort(device)
// gphoto2 uses 1-based file index; devicePath stores the index as "#N"
val fileIndex = photo.devicePath.removePrefix("#")
val destFile = destinationDir.resolve(photo.filename)
// --get-file downloads to current dir; we set working dir to destinationDir
val output = runGphoto2InDir(
destinationDir,
"--port", port,
"--get-file", fileIndex,
"--filename", photo.filename,
"--force-overwrite",
)
if (output.contains("Error") && !output.contains("Saving file as")) {
throw DeviceException("gphoto2 pull failed for ${photo.filename}: $output")
val output = runHelper("pull", photo.devicePath, destFile.absolutePathString())
if (output.contains("Error") || !destFile.toFile().exists()) {
throw DeviceException("AFC pull failed for ${photo.filename}: $output")
}
log.info("Pulled ${photo.filename}${destFile.absolutePathString()}")
return destFile
}
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
val port = requirePort(device)
val fileIndex = photo.devicePath.removePrefix("#")
val output = runGphoto2("--port", port, "--delete-file", fileIndex)
val output = runHelper("delete", photo.devicePath)
if (output.contains("Error")) {
throw DeviceException("Failed to delete ${photo.filename}: $output")
throw DeviceException("AFC delete failed for ${photo.filename}: $output")
}
log.info("Deleted ${photo.filename} from device")
}
/**
* gphoto2 doesn't provide a remote hash command, so we return null.
* The Copier will verify by comparing the local copy against the pulled bytes.
*/
/** AFC doesn't provide remote checksums — verified by size + local SHA-256 in Copier */
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? = null
// -------------------------------------------------------------------------
// Internal helpers
// Helpers
// -------------------------------------------------------------------------
/**
* Parse a single line from `gphoto2 --list-files`.
* Example: "#1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01"
*/
private fun parseGphoto2FileLine(line: String): PhonePhoto? {
return try {
val trimmed = line.trim()
val indexEnd = trimmed.indexOf(' ')
val index = trimmed.substring(0, indexEnd).trimStart('#')
private fun runHelper(vararg args: String): String {
val cmd = listOf(python3Bin, helperScript) + args.toList()
log.debug("Running: ${cmd.joinToString(" ")}")
// Split remainder on multiple spaces
val rest = trimmed.substring(indexEnd).trim().split(Regex("\\s{2,}"))
if (rest.size < 3) return null
val process = ProcessBuilder(cmd)
.redirectErrorStream(false)
.start()
val filename = rest[0].trim()
// rest[1] = "5906 KB" or "5906 kB"
val sizeStr = rest[1].trim().split(" ").firstOrNull() ?: "0"
val sizeKb = sizeStr.toLongOrNull() ?: 0L
// rest[2] could be "2026-07-15 14:22:01" (split might be further)
val datePart = rest.drop(2).joinToString(" ").trim()
val dateTaken = try {
LocalDateTime.parse(datePart, gphotoDateFormatter)
.atZone(ZoneId.systemDefault()).toInstant()
} catch (_: Exception) {
Instant.EPOCH
}
val stdout = process.inputStream.bufferedReader().readText()
val stderr = process.errorStream.bufferedReader().readText()
process.waitFor()
val ext = filename.substringAfterLast(".", "").lowercase()
val mediaType = when (ext) {
"mp4", "mov", "3gp", "avi" -> MediaType.VIDEO
"jpg", "jpeg", "heic", "heif", "png", "dng" -> MediaType.PHOTO
else -> MediaType.UNKNOWN
}
PhonePhoto(
devicePath = "#$index",
filename = filename,
sizeBytes = sizeKb * 1024,
dateTaken = dateTaken,
// iPhones always have camera Make/Model EXIF — we set it from device name
// since gphoto2 --list-files doesn't expose EXIF inline
exifMake = "Apple",
exifModel = null, // populated later if needed
mediaType = mediaType,
)
} catch (e: Exception) {
log.debug("Could not parse gphoto2 line: $line${e.message}")
null
}
if (stderr.isNotBlank()) log.warn("iOS helper stderr: $stderr")
return stdout.trim()
}
/**
* Detect Live Photo pairs by matching HEIC + MOV files with the same base name
* (e.g. IMG_0001.HEIC + IMG_0001.MOV). Tags both sides appropriately.
* Extract the bundled helper script from the JAR resources to a temp file.
* Returns the absolute path to the extracted script.
*/
private fun extractHelperScript(): String {
val resourcePath = "/scripts/photophetch-ios-helper.py"
val stream = IosConnector::class.java.getResourceAsStream(resourcePath)
?: throw IllegalStateException(
"Bundled iOS helper script not found in JAR. " +
"Expected resource: $resourcePath"
)
val tempFile = java.io.File.createTempFile("photophetch-ios-helper", ".py")
tempFile.deleteOnExit()
tempFile.outputStream().use { out -> stream.copyTo(out) }
tempFile.setExecutable(true)
log.info("Extracted iOS helper script to ${tempFile.absolutePath}")
return tempFile.absolutePath
}
/**
* Detect Live Photo pairs by matching HEIC/HEIF + MOV files with the same base name.
*/
private fun tagLivePhotoPairs(photos: List<PhonePhoto>): List<PhonePhoto> {
// Build a map from base name (no extension) → list of photos
val byBaseName = photos.groupBy { it.filename.substringBeforeLast(".", it.filename) }
return photos.map { photo ->
val baseName = photo.filename.substringBeforeLast(".", photo.filename)
val siblings = byBaseName[baseName] ?: return@map photo
val hasHeic = siblings.any {
it.filename.endsWith(".HEIC", ignoreCase = true) ||
it.filename.endsWith(".HEIF", ignoreCase = true)
}
val hasMov = siblings.any {
it.filename.endsWith(".MOV", ignoreCase = true)
}
val hasHeic = siblings.any { it.filename.endsWith(".HEIC", ignoreCase = true) || it.filename.endsWith(".HEIF", ignoreCase = true) }
val hasMov = siblings.any { it.filename.endsWith(".MOV", ignoreCase = true) }
if (!hasHeic || !hasMov) return@map photo
val ext = photo.filename.substringAfterLast(".", "").lowercase()
when (ext) {
"heic", "heif" -> photo.copy(isLivePhotoStill = true)
@@ -230,26 +192,4 @@ class IosConnector(
}
}
}
private fun requirePort(device: DeviceInfo): String =
device.gphotoPort ?: throw DeviceException("No gphoto2 port for device: ${device.displayName}")
private fun runGphoto2(vararg args: String): String = runGphoto2InDir(null, *args)
private fun runGphoto2InDir(workingDir: Path?, vararg args: String): String {
val cmd = buildList {
add(gphoto2Bin)
addAll(args)
}
log.debug("Running: ${cmd.joinToString(" ")}")
val builder = ProcessBuilder(cmd).redirectErrorStream(true)
if (workingDir != null) builder.directory(workingDir.toFile())
val process = builder.start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
return output
}
}

View File

@@ -21,6 +21,6 @@ data class AppConfig(
val heicWarningDismissed: Boolean = false,
/** Path to adb binary, or null to use PATH */
val adbPath: String? = null,
/** Path to gphoto2 binary, or null to use PATH */
val gphoto2Path: String? = null,
/** Path to python3 binary, or null to use PATH (needed for iOS via pymobiledevice3) */
val python3Path: String? = null,
)

View File

@@ -100,7 +100,7 @@ class AppState(private val configStore: ConfigStore) {
try {
val devices = withContext(Dispatchers.IO) {
val android = AndroidConnector(_config.value.adbPath ?: "adb")
val ios = IosConnector(_config.value.gphoto2Path ?: "gphoto2")
val ios = IosConnector(_config.value.python3Path ?: "python3")
android.detectDevices() + ios.detectDevices()
}
_deviceScan.value = DeviceScanState.Found(devices)
@@ -345,6 +345,6 @@ class AppState(private val configStore: ConfigStore) {
// -------------------------------------------------------------------------
private fun connectorFor(device: DeviceInfo): DeviceConnector = when (device.type) {
DeviceType.ANDROID -> AndroidConnector(_config.value.adbPath ?: "adb")
DeviceType.IOS -> IosConnector(_config.value.gphoto2Path ?: "gphoto2")
DeviceType.IOS -> IosConnector(_config.value.python3Path ?: "python3")
}
}

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
photophetch-ios-helper.py
Thin wrapper around pymobiledevice3 AFC for PhotoPhetch iOS connectivity.
Called by IosConnector as a subprocess.
Usage:
python3 photophetch-ios-helper.py list-photos
python3 photophetch-ios-helper.py pull /DCIM/148APPLE/IMG_001.HEIC /tmp/dest.heic
python3 photophetch-ios-helper.py list-devices
Output is JSON on stdout. Errors go to stderr with exit code 1.
"""
import sys
import json
import os
from datetime import datetime
MEDIA_EXTENSIONS = {
'jpg', 'jpeg', 'heic', 'heif', 'avif', 'webp', 'png',
'dng', 'raw', 'arw', 'cr2', 'cr3', 'nef', 'orf', 'rw2',
'tiff', 'tif', 'bmp',
'mp4', 'm4v', 'mov', 'mpeg', 'mpg', '3gp', '3g2', 'avi', 'mkv', 'webm',
}
VIDEO_EXTENSIONS = {
'mp4', 'm4v', 'mov', 'mpeg', 'mpg', '3gp', '3g2', 'avi', 'mkv', 'webm',
}
def get_afc_service():
from pymobiledevice3.lockdown import create_using_usbmux
from pymobiledevice3.services.afc import AfcService
lockdown = create_using_usbmux()
return AfcService(lockdown), lockdown
def cmd_list_devices():
try:
from pymobiledevice3.usbmux import select_devices_by_connection_type
devices = select_devices_by_connection_type(connection_type='USB')
result = [{'udid': d.serial, 'name': getattr(d, 'label', d.serial)} for d in devices]
print(json.dumps(result))
except Exception as e:
# fallback: try lockdown
try:
from pymobiledevice3.lockdown import create_using_usbmux
lockdown = create_using_usbmux()
print(json.dumps([{
'udid': lockdown.udid,
'name': lockdown.display_name or lockdown.udid,
}]))
except Exception as e2:
print(json.dumps([]))
def cmd_list_photos():
try:
afc, _ = get_afc_service()
# List all DCIM subdirectories
try:
dcim_entries = afc.listdir('/DCIM')
except Exception:
print(json.dumps([]))
return
photos = []
current_year = datetime.now().year
for folder in sorted(dcim_entries):
folder_path = f'/DCIM/{folder}'
try:
entries = afc.listdir(folder_path)
except Exception:
continue
for filename in entries:
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
if ext not in MEDIA_EXTENSIONS:
continue
device_path = f'{folder_path}/{filename}'
# Get file info
try:
info = afc.stat(device_path)
size_bytes = int(info.get('st_size', 0))
# st_mtime is nanoseconds since epoch on AFC
mtime_ns = int(info.get('st_mtime', 0))
mtime_s = mtime_ns / 1_000_000_000 if mtime_ns > 1_000_000_000 else mtime_ns
date_iso = datetime.fromtimestamp(mtime_s).isoformat() if mtime_s > 0 else None
except Exception:
size_bytes = 0
date_iso = None
photos.append({
'devicePath': device_path,
'filename': filename,
'sizeBytes': size_bytes,
'dateIso': date_iso,
'sourceFolder': folder,
'mediaType': 'VIDEO' if ext in VIDEO_EXTENSIONS else 'PHOTO',
'isHeic': ext in ('heic', 'heif'),
})
print(json.dumps(photos))
except Exception as e:
print(f'Error: {e}', file=sys.stderr)
sys.exit(1)
def cmd_pull(device_path: str, local_path: str):
try:
afc, _ = get_afc_service()
os.makedirs(os.path.dirname(local_path), exist_ok=True)
with afc.open(device_path, 'rb') as remote_f:
with open(local_path, 'wb') as local_f:
while True:
chunk = remote_f.read(64 * 1024)
if not chunk:
break
local_f.write(chunk)
print(json.dumps({'ok': True, 'path': local_path}))
except Exception as e:
print(f'Error: {e}', file=sys.stderr)
sys.exit(1)
def cmd_delete(device_path: str):
try:
afc, _ = get_afc_service()
afc.rm(device_path)
print(json.dumps({'ok': True}))
except Exception as e:
print(f'Error: {e}', file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
if len(sys.argv) < 2:
print('Usage: photophetch-ios-helper.py <command> [args]', file=sys.stderr)
sys.exit(1)
command = sys.argv[1]
if command == 'list-devices':
cmd_list_devices()
elif command == 'list-photos':
cmd_list_photos()
elif command == 'pull' and len(sys.argv) == 4:
cmd_pull(sys.argv[2], sys.argv[3])
elif command == 'delete' and len(sys.argv) == 3:
cmd_delete(sys.argv[2])
else:
print(f'Unknown command: {command}', file=sys.stderr)
sys.exit(1)