Skip to content

Commit 1f37947

Browse files
committed
feat: Make python daemon start tunneld
1 parent 7167849 commit 1f37947

File tree

5 files changed

+134
-6
lines changed

5 files changed

+134
-6
lines changed

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ java {
1616

1717
dependencies {
1818
api("org.json:json:20250517")
19+
implementation("com.badlogicgames.jnigen:jnigen-commons:3.1.1")
1920
testImplementation(platform("org.junit:junit-bom:5.10.0"))
2021
testImplementation("org.junit.jupiter:junit-jupiter")
2122
}

src/main/java/io/github/berstanio/pymobiledevice3/daemon/DaemonHandler.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.github.berstanio.pymobiledevice3.daemon;
22

3+
import com.badlogic.gdx.jnigen.commons.HostDetection;
4+
import com.badlogic.gdx.jnigen.commons.Os;
35
import io.github.berstanio.pymobiledevice3.venv.PyInstallation;
46

57
import java.io.BufferedReader;
@@ -27,14 +29,14 @@ public class DaemonHandler {
2729

2830

2931
public static File getTempDir() {
30-
if (System.getProperty("os.name").toLowerCase().contains("windows"))
32+
if (HostDetection.os == Os.Windows)
3133
return BASE_TEMP_DIR_WIN;
3234
else
3335
return BASE_TEMP_DIR_UNIX;
3436
}
3537

3638
public static File getPortFile() {
37-
if (System.getProperty("os.name").toLowerCase().contains("windows"))
39+
if (HostDetection.os == Os.Windows)
3840
return WINDOWS_PORT_PATH;
3941
else
4042
return UNIX_PORT_PATH;

src/main/java/io/github/berstanio/pymobiledevice3/ipc/PyMobileDevice3IPC.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,13 +290,30 @@ public CompletableFuture<Void> forceKillDaemon() {
290290
return CompletableFuture.completedFuture(null);
291291
}
292292

293+
public CompletableFuture<Boolean> isTunneldRunning() {
294+
JSONObject object = new JSONObject();
295+
object.put("command", "is_tunneld_running");
296+
return createRequest(object, (future, jsonObject) -> {
297+
future.complete(jsonObject.getBoolean("result"));
298+
});
299+
}
300+
301+
public CompletableFuture<Void> ensureTunneldRunning() {
302+
JSONObject object = new JSONObject();
303+
object.put("command", "ensure_tunneld_running");
304+
return createRequest(object, (future, jsonObject) -> {
305+
future.complete(null);
306+
});
307+
}
308+
293309
public static void main(String[] args) throws IOException {
294310
PyInstallation installation = PyInstallationHandler.install(new File("build/pyenv/"));
295311
DaemonHandler.startDaemon(installation);
296312
try (PyMobileDevice3IPC ipc = new PyMobileDevice3IPC()) {
297313
//JSONObject object = ipc.decodePList(new File("/Volumes/ExternalSSD/IdeaProjects/MOE-Upstream/moe/samples-java/Calculator/ios/build/moe/xcodebuild/Release-iphoneos/ios.app/Info.plist")).join();
298314
//System.out.println(object.getString("CFBundleExecutable"));
299315
//for (DeviceInfo info : ipc.listDevices().join())
316+
ipc.ensureTunneldRunning().join();
300317

301318
//CompletableFuture<String> future = ipc.installApp(ipc.getDevice(null).join(), new File("/Volumes/ExternalSSD/IdeaProjects/MOE-Upstream/moe/samples-java/Calculator/ios/build/moe/xcodebuild/Release-iphoneos/ios.app"), InstallMode.UPGRADE, progress -> System.out.println("Progress: " + progress + "%"));
302319
DeviceInfo info = ipc.getDevice(null).join();

src/main/java/io/github/berstanio/pymobiledevice3/venv/PyInstallationHandler.java

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
package io.github.berstanio.pymobiledevice3.venv;
22

3+
import com.badlogic.gdx.jnigen.commons.HostDetection;
4+
import com.badlogic.gdx.jnigen.commons.Os;
5+
36
import java.io.File;
47
import java.io.IOException;
58
import java.io.InputStream;
69
import java.nio.charset.StandardCharsets;
710
import java.nio.file.Files;
11+
import java.nio.file.Path;
812
import java.nio.file.StandardCopyOption;
913

1014
public class PyInstallationHandler {
@@ -26,7 +30,8 @@ public static PyInstallation install(File directory) {
2630
// Env is valid. Copy files again, just in case
2731
copyFiles(directory);
2832
installRequirements(directory);
29-
return new PyInstallation(venv, new File(venv, PYTHON_PATH), new File(directory, HANDLER_NAME));
33+
34+
return finalizeInstallation(directory);
3035
}
3136
if (!venv.delete())
3237
throw new IllegalStateException(venv.getAbsolutePath() + " exists, is invalid and cannot be deleted");
@@ -66,7 +71,38 @@ public static PyInstallation install(File directory) {
6671

6772
installRequirements(directory);
6873

69-
return new PyInstallation(venv, new File(venv, PYTHON_PATH), new File(directory, HANDLER_NAME));
74+
return finalizeInstallation(directory);
75+
}
76+
77+
private static PyInstallation finalizeInstallation(File directory) {
78+
if (HostDetection.os == Os.MacOsX) {
79+
// MacOS is dumb and launching tunneld with osascript looses file perm chain.
80+
// So if we install venv on external hard drive, it will fail
81+
// So we copy to temp dir, so everything has perms on it
82+
File temp = copyToTempDir(directory);
83+
File tmpVEnv = new File(temp, VENV_NAME);
84+
return new PyInstallation(tmpVEnv, new File(tmpVEnv, PYTHON_PATH), new File(temp, HANDLER_NAME));
85+
} else {
86+
File venv = new File(directory, VENV_NAME);
87+
return new PyInstallation(venv, new File(venv, PYTHON_PATH), new File(directory, HANDLER_NAME));
88+
}
89+
}
90+
91+
private static File copyToTempDir(File directory) {
92+
try {
93+
Path tempDir = Files.createTempDirectory("javapymobiledevice3");
94+
Path sourceDir = directory.toPath();
95+
int code = new ProcessBuilder("rsync", "-a", sourceDir + "/", tempDir + "/")
96+
.inheritIO()
97+
.start()
98+
.waitFor();
99+
if (code != 0)
100+
throw new IllegalStateException("Failed to copy " + directory.getAbsolutePath() + " to " + tempDir.toAbsolutePath());
101+
102+
return tempDir.toFile();
103+
} catch (IOException | InterruptedException e) {
104+
throw new RuntimeException(e);
105+
}
70106
}
71107

72108
private static void copyFiles(File directory) {

src/main/resources/handler.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,30 @@
11
import atexit
22
import json
33
import os
4+
import platform
45
import queue
56
import select
7+
import subprocess
68
import sys
79
import threading
810
import traceback
911
from pathlib import Path
1012
import asyncio
1113

1214
from threading import Thread
15+
from time import sleep
16+
17+
import requests
1318
from packaging.version import Version
1419

1520
import pymobiledevice3.usbmux as usbmux
1621
from pymobiledevice3.exceptions import *
22+
from pymobiledevice3.remote.common import TunnelProtocol
1723
from pymobiledevice3.services.installation_proxy import InstallationProxyService
1824
from pymobiledevice3.services.mobile_image_mounter import auto_mount
1925
from pymobiledevice3.tcp_forwarder import LockdownTcpForwarder, UsbmuxTcpForwarder
20-
from pymobiledevice3.tunneld.api import get_tunneld_device_by_udid
26+
from pymobiledevice3.tunneld.api import get_tunneld_device_by_udid, TUNNELD_DEFAULT_ADDRESS
27+
from pymobiledevice3.tunneld.server import TunneldRunner
2128
from pymobiledevice3.usbmux import *
2229
from pymobiledevice3.lockdown import create_using_usbmux
2330
import plistlib
@@ -200,6 +207,60 @@ def auto_mount_image(id, lockdown, writer):
200207
write_dispatcher.write_reply(writer, reply)
201208

202209

210+
def start_tunneld():
211+
if platform.system() == "Darwin":
212+
print("Starting tunneld as process")
213+
python_executable = sys.executable
214+
start_script = f'do shell script "sudo {python_executable} -m pymobiledevice3 remote tunneld" with administrator privileges'
215+
print("Running \"" + start_script + "\"")
216+
process = subprocess.Popen(['osascript', '-e', start_script], stdout=None, stderr=None)
217+
print(f"Started tunneld process with pid {process.pid}")
218+
else:
219+
print("Starting tunneld as thread")
220+
def _worker():
221+
TunneldRunner.create(*TUNNELD_DEFAULT_ADDRESS, protocol=TunnelProtocol.DEFAULT)
222+
223+
webserver_thread = threading.Thread(target=_worker, daemon=True, name="Python-Tunneld")
224+
webserver_thread.start()
225+
226+
for i in range(60):
227+
if is_tunneld_running():
228+
print("tunneld successfully started")
229+
return
230+
sleep(1)
231+
raise RuntimeError("Failed launching tunneld service")
232+
233+
def is_tunneld_running():
234+
try:
235+
response = requests.get(f"http://{TUNNELD_DEFAULT_ADDRESS[0]}:{TUNNELD_DEFAULT_ADDRESS[1]}/hello")
236+
if response.status_code != 200:
237+
raise RuntimeError()
238+
239+
data = response.json()
240+
if data.get("message") != "Hello, I'm alive":
241+
raise RuntimeError()
242+
# Tunneld seems running happily
243+
return True
244+
except Exception:
245+
return False
246+
247+
def shutdown_tunneld():
248+
if is_tunneld_running():
249+
try:
250+
response = requests.get(f"http://{TUNNELD_DEFAULT_ADDRESS[0]}:{TUNNELD_DEFAULT_ADDRESS[1]}/shutdown")
251+
if response.status_code != 200:
252+
raise RuntimeError("Status code was " + str(response.status_code))
253+
print("tunneld shutdown request successful")
254+
except Exception as e:
255+
print("Failed tunneld teardown", e)
256+
257+
def ensure_tunneld_running():
258+
if not is_tunneld_running():
259+
start_tunneld()
260+
print("tunneld is not running - starting")
261+
else:
262+
print("tunneld is already running")
263+
203264
def debugserver_connect(id, lockdown, port, writer):
204265
try:
205266
discovery_service = get_tunneld_device_by_udid(lockdown.udid)
@@ -292,7 +353,7 @@ def handle_command(command, writer):
292353

293354
command_type = res['command']
294355
if command_type == "exit":
295-
print("Exciting by request")
356+
print("Exiting by request")
296357
atexit._run_exitfuncs()
297358
os._exit(0)
298359
elif command_type == "list_devices":
@@ -317,6 +378,16 @@ def handle_command(command, writer):
317378
elif command_type == "usbmux_forwarder_close":
318379
usbmux_forward_close(id, res['local_port'], writer)
319380
return
381+
elif command_type == "ensure_tunneld_running":
382+
ensure_tunneld_running()
383+
reply = {"id": id, "state": "completed"}
384+
write_dispatcher.write_reply(writer, reply)
385+
return
386+
elif command_type == "is_tunneld_running":
387+
res = is_tunneld_running()
388+
reply = {"id": id, "state": "completed", "result": res}
389+
write_dispatcher.write_reply(writer, reply)
390+
return
320391

321392
# Now come the device targetted functions
322393
device_id = res['device_id']
@@ -355,6 +426,7 @@ def main():
355426
print(f"Written port {port} to file {path}")
356427

357428
atexit.register(os.remove, path)
429+
atexit.register(shutdown_tunneld)
358430
print(f"Start listening on port {port}")
359431
while True:
360432
client_socket, client_address = server.accept()

0 commit comments

Comments
 (0)