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:
@@ -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)
|
||||||
|
|||||||
@@ -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() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
|
||||||
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,
|
'udid': lockdown.udid,
|
||||||
'name': lockdown.display_name or lockdown.udid,
|
'name': lockdown.display_name or lockdown.udid,
|
||||||
}]))
|
}]
|
||||||
except Exception as e2:
|
print(json.dumps(result))
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error: {e}', file=sys.stderr)
|
||||||
|
# Return empty list rather than failing — device not connected
|
||||||
print(json.dumps([]))
|
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
|
||||||
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
|
size_bytes = 0
|
||||||
date_iso = None
|
date_iso = None
|
||||||
|
try:
|
||||||
|
info = await afc.stat(device_path)
|
||||||
|
size_bytes = int(info.get('st_size', 0))
|
||||||
|
mtime_ns = int(info.get('st_mtime', 0))
|
||||||
|
# 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:
|
||||||
|
pass
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user