Fix iOS helper: async pymobiledevice3 API + auto-discover pipx Python

The helper script now uses asyncio.run() for all pymobiledevice3 calls
since create_using_usbmux() and AfcService methods are async in newer
versions.

IosConnector: auto-discovers the pipx venv Python at
~/Library/Application Support/pipx/venvs/pymobiledevice3/bin/python3
instead of using system python3 (which doesn't have the module).
Falls back to ~/.local/pipx/... paths too.

AppConfig python3Path acts as an override if auto-discovery fails.
This commit is contained in:
Kyle Bolen
2026-07-28 08:06:13 +00:00
parent 1ae16f0ead
commit 7f11a4b7d6
3 changed files with 81 additions and 58 deletions

View File

@@ -28,19 +28,44 @@ import kotlin.io.path.absolutePathString
* Requires pymobiledevice3 installed via pipx: * Requires pymobiledevice3 installed via pipx:
* brew install pipx && pipx install pymobiledevice3 * brew install pipx && pipx install pymobiledevice3
* *
* Uses a bundled Python helper script (photophetch-ios-helper.py) that * The helper script is bundled in the JAR and extracted to a temp file.
* calls the pymobiledevice3 AFC service to list and pull files. * 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 * Auto-discovers the pipx Python at:
* on iOS 17+ due to Apple tightening PTP access. * ~/Library/Application Support/pipx/venvs/pymobiledevice3/bin/python3
*/ */
class IosConnector( class IosConnector(
private val python3Bin: String = "python3", pythonBinOverride: String? = null,
) : DeviceConnector { ) : DeviceConnector {
private val log = LoggerFactory.getLogger(IosConnector::class.java) private val log = LoggerFactory.getLogger(IosConnector::class.java)
private val json = Json { ignoreUnknownKeys = true } 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 */ /** Path to the bundled helper script, extracted to a temp location on first use */
private val helperScript: String by lazy { extractHelperScript() } private val helperScript: String by lazy { extractHelperScript() }
@@ -144,7 +169,7 @@ class IosConnector(
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
private fun runHelper(vararg args: String): String { 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(" ")}") log.debug("Running: ${cmd.joinToString(" ")}")
val process = ProcessBuilder(cmd) val process = ProcessBuilder(cmd)

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.python3Path ?: "python3") val ios = IosConnector(_config.value.python3Path.takeIf { !it.isNullOrBlank() })
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) {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
internal fun connectorFor(device: DeviceInfo): DeviceConnector = when (device.type) { internal 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.python3Path ?: "python3") DeviceType.IOS -> IosConnector(_config.value.python3Path.takeIf { !it.isNullOrBlank() })
} }
} }

View File

@@ -2,13 +2,14 @@
""" """
photophetch-ios-helper.py 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. Called by IosConnector as a subprocess.
Usage: Usage:
python3 photophetch-ios-helper.py list-devices
python3 photophetch-ios-helper.py list-photos 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 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. 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 sys
import json import json
import os import os
import asyncio
from datetime import datetime from datetime import datetime
MEDIA_EXTENSIONS = { MEDIA_EXTENSIONS = {
@@ -30,50 +32,48 @@ VIDEO_EXTENSIONS = {
} }
def get_afc_service(): async def get_lockdown():
from pymobiledevice3.lockdown import create_using_usbmux from pymobiledevice3.lockdown import create_using_usbmux
return await create_using_usbmux()
async def get_afc(lockdown):
from pymobiledevice3.services.afc import AfcService from pymobiledevice3.services.afc import AfcService
lockdown = create_using_usbmux() return AfcService(lockdown)
return AfcService(lockdown), lockdown
def cmd_list_devices(): async def cmd_list_devices():
try: try:
from pymobiledevice3.usbmux import select_devices_by_connection_type lockdown = await get_lockdown()
devices = select_devices_by_connection_type(connection_type='USB') result = [{
result = [{'udid': d.serial, 'name': getattr(d, 'label', d.serial)} for d in devices] 'udid': lockdown.udid,
'name': lockdown.display_name or lockdown.udid,
}]
print(json.dumps(result)) print(json.dumps(result))
except Exception as e: except Exception as e:
# fallback: try lockdown print(f'Error: {e}', file=sys.stderr)
try: # Return empty list rather than failing — device not connected
from pymobiledevice3.lockdown import create_using_usbmux print(json.dumps([]))
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(): async def cmd_list_photos():
try: try:
afc, _ = get_afc_service() lockdown = await get_lockdown()
afc = await get_afc(lockdown)
# List all DCIM subdirectories
try: try:
dcim_entries = afc.listdir('/DCIM') dcim_entries = await afc.listdir('/DCIM')
except Exception: except Exception as e:
print(f'Error listing DCIM: {e}', file=sys.stderr)
print(json.dumps([])) print(json.dumps([]))
return return
photos = [] photos = []
current_year = datetime.now().year
for folder in sorted(dcim_entries): for folder in sorted(dcim_entries):
folder_path = f'/DCIM/{folder}' folder_path = f'/DCIM/{folder}'
try: try:
entries = afc.listdir(folder_path) entries = await afc.listdir(folder_path)
except Exception: except Exception:
continue continue
@@ -84,17 +84,19 @@ def cmd_list_photos():
device_path = f'{folder_path}/{filename}' device_path = f'{folder_path}/{filename}'
# Get file info # Get file metadata via stat
size_bytes = 0
date_iso = None
try: try:
info = afc.stat(device_path) info = await afc.stat(device_path)
size_bytes = int(info.get('st_size', 0)) 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_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 # st_mtime may be nanoseconds or seconds depending on version
date_iso = datetime.fromtimestamp(mtime_s).isoformat() if mtime_s > 0 else None 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: except Exception:
size_bytes = 0 pass
date_iso = None
photos.append({ photos.append({
'devicePath': device_path, 'devicePath': device_path,
@@ -113,27 +115,23 @@ def cmd_list_photos():
sys.exit(1) sys.exit(1)
def cmd_pull(device_path: str, local_path: str): async def cmd_pull(device_path: str, local_path: str):
try: try:
afc, _ = get_afc_service() lockdown = await get_lockdown()
os.makedirs(os.path.dirname(local_path), exist_ok=True) afc = await get_afc(lockdown)
with afc.open(device_path, 'rb') as remote_f: os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
with open(local_path, 'wb') as local_f: await afc.pull(device_path, local_path)
while True:
chunk = remote_f.read(64 * 1024)
if not chunk:
break
local_f.write(chunk)
print(json.dumps({'ok': True, 'path': local_path})) print(json.dumps({'ok': True, 'path': local_path}))
except Exception as e: except Exception as e:
print(f'Error: {e}', file=sys.stderr) print(f'Error: {e}', file=sys.stderr)
sys.exit(1) sys.exit(1)
def cmd_delete(device_path: str): async def cmd_delete(device_path: str):
try: try:
afc, _ = get_afc_service() lockdown = await get_lockdown()
afc.rm(device_path) afc = await get_afc(lockdown)
await afc.rm(device_path)
print(json.dumps({'ok': True})) print(json.dumps({'ok': True}))
except Exception as e: except Exception as e:
print(f'Error: {e}', file=sys.stderr) print(f'Error: {e}', file=sys.stderr)
@@ -148,13 +146,13 @@ if __name__ == '__main__':
command = sys.argv[1] command = sys.argv[1]
if command == 'list-devices': if command == 'list-devices':
cmd_list_devices() asyncio.run(cmd_list_devices())
elif command == 'list-photos': elif command == 'list-photos':
cmd_list_photos() asyncio.run(cmd_list_photos())
elif command == 'pull' and len(sys.argv) == 4: 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: elif command == 'delete' and len(sys.argv) == 3:
cmd_delete(sys.argv[2]) asyncio.run(cmd_delete(sys.argv[2]))
else: else:
print(f'Unknown command: {command}', file=sys.stderr) print(f'Unknown command: {command}', file=sys.stderr)
sys.exit(1) sys.exit(1)