From 50d6f66116609ba0128690c4a8e392b2f2b02387 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Tue, 28 Jul 2026 07:47:16 +0000 Subject: [PATCH] Replace gphoto2 with pymobiledevice3 for iOS connectivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../photophetch/device/IosConnector.kt | 294 +++++++----------- .../bolenpad/photophetch/model/AppConfig.kt | 4 +- .../com/bolenpad/photophetch/ui/AppState.kt | 4 +- .../scripts/photophetch-ios-helper.py | 160 ++++++++++ 4 files changed, 281 insertions(+), 181 deletions(-) create mode 100644 src/main/resources/scripts/photophetch-ios-helper.py diff --git a/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt b/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt index cf13662..99448a1 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt @@ -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 { - // gphoto2 --auto-detect lists connected cameras/phones - val output = runGphoto2("--auto-detect") - /* - * Output format: - * Model Port - * ---------------------------------------------------------- - * Apple iPhone usb:001,023 - */ - val devices = mutableListOf() - 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 { - 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() - 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): List { - // 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 - } } diff --git a/src/main/kotlin/com/bolenpad/photophetch/model/AppConfig.kt b/src/main/kotlin/com/bolenpad/photophetch/model/AppConfig.kt index 75ad293..381750f 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/model/AppConfig.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/model/AppConfig.kt @@ -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, ) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt index 2d0907e..b94c905 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt @@ -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") } } diff --git a/src/main/resources/scripts/photophetch-ios-helper.py b/src/main/resources/scripts/photophetch-ios-helper.py new file mode 100644 index 0000000..f21fa96 --- /dev/null +++ b/src/main/resources/scripts/photophetch-ios-helper.py @@ -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 [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)