88import java .io .File ;
99import java .io .IOException ;
1010import java .io .InputStream ;
11+ import java .io .Reader ;
12+ import java .io .Writer ;
1113import java .net .MalformedURLException ;
1214import java .net .URL ;
1315import java .nio .charset .StandardCharsets ;
1416import java .nio .file .Files ;
1517import java .nio .file .StandardCopyOption ;
18+ import java .security .MessageDigest ;
19+ import java .security .NoSuchAlgorithmException ;
20+ import java .util .Base64 ;
21+ import java .util .Properties ;
1622
1723public class PyInstallationHandler {
1824
1925 private static final boolean DEBUG = System .getProperty ("java.pymobiledevice3.debug" ) != null ;
2026
2127 private static final String HANDLER_NAME = "handler.py" ;
2228 private static final String REQUIREMENTS_NAME = "requirements.txt" ;
23- private static final String READY_MARKER = ".ready" ;
2429 private static final String PYTHON_DIR = "python" ;
2530
31+ private static final String READY_MARKER = ".ready" ;
32+ private static final String READY_PYTHON = "python" ;
33+ private static final String READY_REQUIREMENTS = "requirements" ;
34+
2635 private static final String PBS_RELEASE = "20260623" ;
2736 private static final String PBS_PYTHON_VERSION = "3.14.6" ;
2837 private static final String PBS_FLAVOR = "install_only_stripped" ;
@@ -41,68 +50,100 @@ public static PyInstallation install(File directory) {
4150 }
4251
4352 public static PyInstallation install (File directory , File ipcBaseDirectory ) {
44- if (isReady (directory ))
45- return toInstallation (directory );
46-
47- prepareDirectory (directory );
48-
4953 try {
50- Files .copy (ipcBaseDirectory .toPath ().resolve (HANDLER_NAME ), directory .toPath ().resolve (HANDLER_NAME ), StandardCopyOption .REPLACE_EXISTING );
51- Files .copy (ipcBaseDirectory .toPath ().resolve (REQUIREMENTS_NAME ), directory .toPath ().resolve (REQUIREMENTS_NAME ), StandardCopyOption .REPLACE_EXISTING );
52- } catch (IOException e ) {
53- throw new RuntimeException ("Failed to copy files" , e );
54+ return install (directory ,
55+ new File (ipcBaseDirectory , HANDLER_NAME ).toURI ().toURL (),
56+ new File (ipcBaseDirectory , REQUIREMENTS_NAME ).toURI ().toURL ());
57+ } catch (MalformedURLException e ) {
58+ throw new RuntimeException ("Failed to resolve IPC URL" , e );
5459 }
55-
56- return installInternal (directory );
5760 }
5861
5962 public static PyInstallation install (File directory , URL handlerFile , URL requirementsFile ) {
60- if (isReady (directory ))
61- return toInstallation (directory );
62-
6363 prepareDirectory (directory );
64+ refresh (new File (directory , HANDLER_NAME ), handlerFile );
65+ refresh (new File (directory , REQUIREMENTS_NAME ), requirementsFile );
66+ return installInternal (directory );
67+ }
6468
65- try {
66- try (InputStream in = handlerFile .openStream ()) {
67- Files .copy (in , directory .toPath ().resolve (HANDLER_NAME ), StandardCopyOption .REPLACE_EXISTING );
68- }
69- try (InputStream in = requirementsFile .openStream ()) {
70- Files .copy (in , directory .toPath ().resolve (REQUIREMENTS_NAME ), StandardCopyOption .REPLACE_EXISTING );
71- }
69+ private static void refresh (File target , URL source ) {
70+ try (InputStream in = source .openStream ()) {
71+ Files .copy (in , target .toPath (), StandardCopyOption .REPLACE_EXISTING );
7272 } catch (IOException e ) {
73- throw new RuntimeException ("Failed to download files" , e );
73+ // Source unreachable (e.g. offline restart): reuse the previously fetched copy.
74+ if (!target .exists ())
75+ throw new RuntimeException ("Failed to fetch " + target .getName (), e );
7476 }
75-
76- return installInternal (directory );
7777 }
7878
7979 private static void prepareDirectory (File directory ) {
80- deleteRecursively (directory );
8180 directory .mkdirs ();
8281 if (!directory .exists () || !directory .isDirectory ())
8382 throw new IllegalArgumentException (directory .getAbsolutePath () + " does not exist or is not a directory" );
8483 }
8584
86- private static boolean isReady (File directory ) {
87- return new File (directory , READY_MARKER ).exists () && pythonExecutable (directory ).exists ();
88- }
89-
9085 private static PyInstallation installInternal (File directory ) {
91- File pythonHome = new File (directory , PYTHON_DIR );
92- if (pythonHome .exists () && !deleteRecursively (pythonHome ))
93- throw new IllegalStateException ("Failed to remove stale interpreter at " + pythonHome .getAbsolutePath ());
86+ String urlHash = hash (standaloneUrl ().getBytes (StandardCharsets .UTF_8 ));
87+ String reqHash = hash (readBytes (new File (directory , REQUIREMENTS_NAME )));
88+
89+ Properties ready = loadReady (directory );
90+ boolean interpreterReady = urlHash .equals (ready .getProperty (READY_PYTHON ))
91+ && pythonExecutable (directory ).exists ();
92+
93+ if (!interpreterReady ) {
94+ File pythonHome = new File (directory , PYTHON_DIR );
95+ if (pythonHome .exists () && !deleteRecursively (pythonHome ))
96+ throw new IllegalStateException ("Failed to remove stale interpreter at " + pythonHome .getAbsolutePath ());
97+ downloadAndExtractInterpreter (directory );
98+ installRequirements (directory );
99+
100+ ready .setProperty (READY_PYTHON , urlHash );
101+ ready .setProperty (READY_REQUIREMENTS , reqHash );
102+ storeReady (directory , ready );
103+ } else if (!reqHash .equals (ready .getProperty (READY_REQUIREMENTS ))) {
104+ installRequirements (directory );
105+ ready .setProperty (READY_REQUIREMENTS , reqHash );
106+ storeReady (directory , ready );
107+ }
108+
109+ return toInstallation (directory );
110+ }
94111
95- downloadAndExtractInterpreter (directory );
96- installRequirements (directory );
112+ private static Properties loadReady (File directory ) {
113+ Properties props = new Properties ();
114+ File f = new File (directory , READY_MARKER );
115+ if (f .exists ()) {
116+ try (Reader r = Files .newBufferedReader (f .toPath (), StandardCharsets .UTF_8 )) {
117+ props .load (r );
118+ } catch (IOException e ) {
119+ return new Properties ();
120+ }
121+ }
122+ return props ;
123+ }
97124
98- try {
99- Files .deleteIfExists (new File (directory , READY_MARKER ).toPath ());
100- Files . createFile ( new File ( directory , READY_MARKER ). toPath () );
125+ private static void storeReady ( File directory , Properties props ) {
126+ try ( Writer w = Files .newBufferedWriter (new File (directory , READY_MARKER ).toPath (), StandardCharsets . UTF_8 )) {
127+ props . store ( w , null );
101128 } catch (IOException e ) {
102129 throw new IllegalStateException ("Failed to write ready marker in " + directory .getAbsolutePath (), e );
103130 }
131+ }
104132
105- return toInstallation (directory );
133+ private static String hash (byte [] bytes ) {
134+ try {
135+ return Base64 .getUrlEncoder ().withoutPadding ().encodeToString (MessageDigest .getInstance ("SHA-256" ).digest (bytes ));
136+ } catch (NoSuchAlgorithmException e ) {
137+ throw new IllegalStateException (e );
138+ }
139+ }
140+
141+ private static byte [] readBytes (File f ) {
142+ try {
143+ return Files .readAllBytes (f .toPath ());
144+ } catch (IOException e ) {
145+ throw new IllegalStateException ("Failed to read " + f .getAbsolutePath (), e );
146+ }
106147 }
107148
108149 private static void downloadAndExtractInterpreter (File directory ) {
0 commit comments