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.DeviceType
import com.bolenpad.photophetch.model.MediaType import com.bolenpad.photophetch.model.MediaType
import com.bolenpad.photophetch.model.PhonePhoto 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 org.slf4j.LoggerFactory
import java.nio.file.Path import java.nio.file.Path
import java.time.Instant import java.time.Instant
@@ -13,215 +23,167 @@ import java.time.format.DateTimeFormatter
import kotlin.io.path.absolutePathString 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]). * Requires pymobiledevice3 installed via pipx:
* Install via: brew install gphoto2 * brew install pipx && pipx install pymobiledevice3
* *
* Important macOS note: The built-in Image Capture daemon (PTPCamera) will * Uses a bundled Python helper script (photophetch-ios-helper.py) that
* conflict with gphoto2. If listing returns no files, run: * calls the pymobiledevice3 AFC service to list and pull files.
* killall PTPCamera
* PhotoPhetch shows instructions for this when no photos are found on a
* connected iPhone.
* *
* Live Photo handling: gphoto2 lists both the .HEIC and the companion .MOV * Replaces the previous gphoto2-based implementation which stopped working
* with matching base names. We detect the pair by comparing filenames and * on iOS 17+ due to Apple tightening PTP access.
* flag [PhonePhoto.isLivePhotoStill] / [PhonePhoto.isLivePhotoMotion].
* The MOV companion is excluded from import by default.
*/ */
class IosConnector( class IosConnector(
private val gphoto2Bin: String = "gphoto2", private val python3Bin: String = "python3",
) : DeviceConnector { ) : DeviceConnector {
private val log = LoggerFactory.getLogger(IosConnector::class.java) private val log = LoggerFactory.getLogger(IosConnector::class.java)
private val json = Json { ignoreUnknownKeys = true }
// gphoto2 date format in --list-files output /** Path to the bundled helper script, extracted to a temp location on first use */
private val gphotoDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") private val helperScript: String by lazy { extractHelperScript() }
override suspend fun detectDevices(): List<DeviceInfo> { override suspend fun detectDevices(): List<DeviceInfo> {
// gphoto2 --auto-detect lists connected cameras/phones return try {
val output = runGphoto2("--auto-detect") val output = runHelper("list-devices")
/* if (output.isBlank() || output.startsWith("Error")) return emptyList()
* 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
// Split on 2+ spaces to separate model from port val array = json.parseToJsonElement(output).jsonArray
val parts = line.trim().split(Regex("\\s{2,}"), limit = 2) array.map { element ->
if (parts.size < 2) continue val obj = element.jsonObject
val model = parts[0].trim() DeviceInfo(
val port = parts[1].trim() displayName = obj["name"]?.jsonPrimitive?.contentOrNull ?: "iPhone",
type = DeviceType.IOS,
if (model.contains("iPhone", ignoreCase = true) || gphotoPort = obj["udid"]?.jsonPrimitive?.contentOrNull,
model.contains("iPad", ignoreCase = true) ||
model.contains("Apple", ignoreCase = true)
) {
devices.add(
DeviceInfo(
displayName = model,
type = DeviceType.IOS,
gphotoPort = port,
)
) )
} }
} catch (e: Exception) {
log.warn("iOS device detection failed: ${e.message}")
emptyList()
} }
return devices
} }
override suspend fun listPhotos(device: DeviceInfo): List<PhonePhoto> { 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 array = json.parseToJsonElement(output).jsonArray
val output = runGphoto2("--port", port, "--list-files") 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"
/* val dateTaken = if (dateIso != null) {
* Output format (each file): try { Instant.parse(dateIso + "Z") } catch (_: Exception) {
* #1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01 try { LocalDateTime.parse(dateIso).atZone(ZoneId.systemDefault()).toInstant() }
* #2 IMG_0001.MOV 12344 KB 2026-07-15 14:22:01 catch (_: Exception) { Instant.EPOCH }
* #3 IMG_0002.JPG 3021 KB 2026-07-15 14:23:45 }
*/ } else Instant.EPOCH
val rawPhotos = mutableListOf<PhonePhoto>()
for (line in output.lines()) { PhonePhoto(
if (!line.trimStart().startsWith("#")) continue devicePath = devicePath,
val photo = parseGphoto2FileLine(line) ?: continue filename = filename,
rawPhotos.add(photo) 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 { 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) val destFile = destinationDir.resolve(photo.filename)
val output = runHelper("pull", photo.devicePath, destFile.absolutePathString())
// --get-file downloads to current dir; we set working dir to destinationDir if (output.contains("Error") || !destFile.toFile().exists()) {
val output = runGphoto2InDir( throw DeviceException("AFC pull failed for ${photo.filename}: $output")
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")
} }
log.info("Pulled ${photo.filename}${destFile.absolutePathString()}") log.info("Pulled ${photo.filename}${destFile.absolutePathString()}")
return destFile return destFile
} }
override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) { override suspend fun deletePhoto(device: DeviceInfo, photo: PhonePhoto) {
val port = requirePort(device) val output = runHelper("delete", photo.devicePath)
val fileIndex = photo.devicePath.removePrefix("#")
val output = runGphoto2("--port", port, "--delete-file", fileIndex)
if (output.contains("Error")) { 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") log.info("Deleted ${photo.filename} from device")
} }
/** /** AFC doesn't provide remote checksums — verified by size + local SHA-256 in Copier */
* 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.
*/
override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? = null override suspend fun remoteChecksum(device: DeviceInfo, photo: PhonePhoto): String? = null
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Internal helpers // Helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/** private fun runHelper(vararg args: String): String {
* Parse a single line from `gphoto2 --list-files`. val cmd = listOf(python3Bin, helperScript) + args.toList()
* Example: "#1 IMG_0001.HEIC 5906 KB 2026-07-15 14:22:01" log.debug("Running: ${cmd.joinToString(" ")}")
*/
private fun parseGphoto2FileLine(line: String): PhonePhoto? {
return try {
val trimmed = line.trim()
val indexEnd = trimmed.indexOf(' ')
val index = trimmed.substring(0, indexEnd).trimStart('#')
// Split remainder on multiple spaces val process = ProcessBuilder(cmd)
val rest = trimmed.substring(indexEnd).trim().split(Regex("\\s{2,}")) .redirectErrorStream(false)
if (rest.size < 3) return null .start()
val filename = rest[0].trim() val stdout = process.inputStream.bufferedReader().readText()
// rest[1] = "5906 KB" or "5906 kB" val stderr = process.errorStream.bufferedReader().readText()
val sizeStr = rest[1].trim().split(" ").firstOrNull() ?: "0" process.waitFor()
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 ext = filename.substringAfterLast(".", "").lowercase() if (stderr.isNotBlank()) log.warn("iOS helper stderr: $stderr")
val mediaType = when (ext) { return stdout.trim()
"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
}
} }
/** /**
* Detect Live Photo pairs by matching HEIC + MOV files with the same base name * Extract the bundled helper script from the JAR resources to a temp file.
* (e.g. IMG_0001.HEIC + IMG_0001.MOV). Tags both sides appropriately. * 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> { 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) } val byBaseName = photos.groupBy { it.filename.substringBeforeLast(".", it.filename) }
return photos.map { photo -> return photos.map { photo ->
val baseName = photo.filename.substringBeforeLast(".", photo.filename) val baseName = photo.filename.substringBeforeLast(".", photo.filename)
val siblings = byBaseName[baseName] ?: return@map photo val siblings = byBaseName[baseName] ?: return@map photo
val hasHeic = siblings.any { it.filename.endsWith(".HEIC", ignoreCase = true) || it.filename.endsWith(".HEIF", ignoreCase = true) }
val hasHeic = siblings.any { val hasMov = siblings.any { it.filename.endsWith(".MOV", ignoreCase = true) }
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 if (!hasHeic || !hasMov) return@map photo
val ext = photo.filename.substringAfterLast(".", "").lowercase() val ext = photo.filename.substringAfterLast(".", "").lowercase()
when (ext) { when (ext) {
"heic", "heif" -> photo.copy(isLivePhotoStill = true) "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, val heicWarningDismissed: Boolean = false,
/** Path to adb binary, or null to use PATH */ /** Path to adb binary, or null to use PATH */
val adbPath: String? = null, val adbPath: String? = null,
/** Path to gphoto2 binary, or null to use PATH */ /** Path to python3 binary, or null to use PATH (needed for iOS via pymobiledevice3) */
val gphoto2Path: String? = null, val python3Path: String? = null,
) )

View File

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