diff --git a/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt b/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt index ff67b88..c60390a 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/device/IosConnector.kt @@ -28,19 +28,44 @@ import kotlin.io.path.absolutePathString * Requires pymobiledevice3 installed via pipx: * brew install pipx && pipx install pymobiledevice3 * - * Uses a bundled Python helper script (photophetch-ios-helper.py) that - * calls the pymobiledevice3 AFC service to list and pull files. + * The helper script is bundled in the JAR and extracted to a temp file. + * It must be run with the pipx virtualenv Python (not system python3) + * since pymobiledevice3 is installed in the pipx venv. * - * Replaces the previous gphoto2-based implementation which stopped working - * on iOS 17+ due to Apple tightening PTP access. + * Auto-discovers the pipx Python at: + * ~/Library/Application Support/pipx/venvs/pymobiledevice3/bin/python3 */ class IosConnector( - private val python3Bin: String = "python3", + pythonBinOverride: String? = null, ) : DeviceConnector { private val log = LoggerFactory.getLogger(IosConnector::class.java) private val json = Json { ignoreUnknownKeys = true } + private val pythonBin: String = pythonBinOverride ?: discoverPython() + + companion object { + private val PIPX_PYTHON_CANDIDATES = listOf( + // macOS pipx default (Intel and Apple Silicon) + "${System.getProperty("user.home")}/Library/Application Support/pipx/venvs/pymobiledevice3/bin/python3", + "${System.getProperty("user.home")}/.local/pipx/venvs/pymobiledevice3/bin/python3", + "/usr/local/pipx/venvs/pymobiledevice3/bin/python3", + ) + + fun discoverPython(): String { + for (candidate in PIPX_PYTHON_CANDIDATES) { + if (java.io.File(candidate).exists()) return candidate + } + // Fallback to system python3 — will fail with "module not found" but gives a clear error + return "python3" + } + + fun isPythonAvailable(): Boolean { + val python = discoverPython() + return java.io.File(python).exists() || python == "python3" + } + } + /** Path to the bundled helper script, extracted to a temp location on first use */ private val helperScript: String by lazy { extractHelperScript() } @@ -144,7 +169,7 @@ class IosConnector( // ------------------------------------------------------------------------- private fun runHelper(vararg args: String): String { - val cmd = listOf(python3Bin, helperScript) + args.toList() + val cmd = listOf(pythonBin, helperScript) + args.toList() log.debug("Running: ${cmd.joinToString(" ")}") val process = ProcessBuilder(cmd) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt index e8385be..7944b3a 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.python3Path ?: "python3") + val ios = IosConnector(_config.value.python3Path.takeIf { !it.isNullOrBlank() }) android.detectDevices() + ios.detectDevices() } _deviceScan.value = DeviceScanState.Found(devices) @@ -345,6 +345,6 @@ class AppState(private val configStore: ConfigStore) { // ------------------------------------------------------------------------- internal fun connectorFor(device: DeviceInfo): DeviceConnector = when (device.type) { DeviceType.ANDROID -> AndroidConnector(_config.value.adbPath ?: "adb") - DeviceType.IOS -> IosConnector(_config.value.python3Path ?: "python3") + DeviceType.IOS -> IosConnector(_config.value.python3Path.takeIf { !it.isNullOrBlank() }) } } diff --git a/src/main/resources/scripts/photophetch-ios-helper.py b/src/main/resources/scripts/photophetch-ios-helper.py index f21fa96..2f502f6 100644 --- a/src/main/resources/scripts/photophetch-ios-helper.py +++ b/src/main/resources/scripts/photophetch-ios-helper.py @@ -2,13 +2,14 @@ """ photophetch-ios-helper.py -Thin wrapper around pymobiledevice3 AFC for PhotoPhetch iOS connectivity. +Async wrapper around pymobiledevice3 AFC for PhotoPhetch iOS connectivity. Called by IosConnector as a subprocess. Usage: + python3 photophetch-ios-helper.py list-devices 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 + python3 photophetch-ios-helper.py delete /DCIM/148APPLE/IMG_001.HEIC Output is JSON on stdout. Errors go to stderr with exit code 1. """ @@ -16,6 +17,7 @@ Output is JSON on stdout. Errors go to stderr with exit code 1. import sys import json import os +import asyncio from datetime import datetime MEDIA_EXTENSIONS = { @@ -30,50 +32,48 @@ VIDEO_EXTENSIONS = { } -def get_afc_service(): +async def get_lockdown(): from pymobiledevice3.lockdown import create_using_usbmux + return await create_using_usbmux() + + +async def get_afc(lockdown): from pymobiledevice3.services.afc import AfcService - lockdown = create_using_usbmux() - return AfcService(lockdown), lockdown + return AfcService(lockdown) -def cmd_list_devices(): +async 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] + lockdown = await get_lockdown() + result = [{ + 'udid': lockdown.udid, + 'name': lockdown.display_name or lockdown.udid, + }] 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([])) + print(f'Error: {e}', file=sys.stderr) + # Return empty list rather than failing — device not connected + print(json.dumps([])) -def cmd_list_photos(): +async def cmd_list_photos(): try: - afc, _ = get_afc_service() + lockdown = await get_lockdown() + afc = await get_afc(lockdown) - # List all DCIM subdirectories try: - dcim_entries = afc.listdir('/DCIM') - except Exception: + dcim_entries = await afc.listdir('/DCIM') + except Exception as e: + print(f'Error listing DCIM: {e}', file=sys.stderr) 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) + entries = await afc.listdir(folder_path) except Exception: continue @@ -84,17 +84,19 @@ def cmd_list_photos(): device_path = f'{folder_path}/{filename}' - # Get file info + # Get file metadata via stat + size_bytes = 0 + date_iso = None try: - info = afc.stat(device_path) + info = await 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 + # st_mtime may be nanoseconds or seconds depending on version + mtime_s = mtime_ns / 1_000_000_000 if mtime_ns > 1_700_000_000_000 else mtime_ns + if mtime_s > 0: + date_iso = datetime.fromtimestamp(mtime_s).isoformat() except Exception: - size_bytes = 0 - date_iso = None + pass photos.append({ 'devicePath': device_path, @@ -113,27 +115,23 @@ def cmd_list_photos(): sys.exit(1) -def cmd_pull(device_path: str, local_path: str): +async 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) + lockdown = await get_lockdown() + afc = await get_afc(lockdown) + os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True) + await afc.pull(device_path, local_path) 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): +async def cmd_delete(device_path: str): try: - afc, _ = get_afc_service() - afc.rm(device_path) + lockdown = await get_lockdown() + afc = await get_afc(lockdown) + await afc.rm(device_path) print(json.dumps({'ok': True})) except Exception as e: print(f'Error: {e}', file=sys.stderr) @@ -148,13 +146,13 @@ if __name__ == '__main__': command = sys.argv[1] if command == 'list-devices': - cmd_list_devices() + asyncio.run(cmd_list_devices()) elif command == 'list-photos': - cmd_list_photos() + asyncio.run(cmd_list_photos()) elif command == 'pull' and len(sys.argv) == 4: - cmd_pull(sys.argv[2], sys.argv[3]) + asyncio.run(cmd_pull(sys.argv[2], sys.argv[3])) elif command == 'delete' and len(sys.argv) == 3: - cmd_delete(sys.argv[2]) + asyncio.run(cmd_delete(sys.argv[2])) else: print(f'Unknown command: {command}', file=sys.stderr) sys.exit(1)