diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 4d90d1a..e23c1bd 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -1,27 +1,34 @@ name: Java CI with Maven on: [push, pull_request] -env: - DEVELOPER_DIR: /Applications/Xcode_13.2.app/Contents/Developer + jobs: build: - runs-on: macos-latest - + strategy: + fail-fast: false + matrix: + # Apple Silicon and Intel: libjcocoa and the arm64 objc_msgSend paths + # are tested natively on each. + runner: [macos-15, macos-15-intel] + runs-on: ${{ matrix.runner }} + steps: - - uses: actions/checkout@v2 - - - name: Cache local Maven repository - uses: actions/cache@v2 - with: - path: ~/.m2/repository - key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} - restore-keys: | - ${{ runner.os }}-maven- - + - uses: actions/checkout@v4 + - name: Set up JDK 11 - uses: actions/setup-java@v1 + uses: actions/setup-java@v4 with: + distribution: temurin java-version: 11 - + cache: maven + - name: Build with Maven run: mvn --batch-mode --no-transfer-progress --update-snapshots verify + + - name: Release build of libjcocoa is universal + run: | + mvn --batch-mode --no-transfer-progress -Drelease=true -DskipTests generate-resources + lipo -archs target/classes/libjcocoa.dylib + for a in arm64 x86_64; do + lipo -archs target/classes/libjcocoa.dylib | tr ' ' '\n' | grep -qx "$a" || { echo "::error::libjcocoa.dylib has no $a slice"; exit 1; } + done diff --git a/README.md b/README.md index a3ac54b..150dab7 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,18 @@ Add the following dependency to your pom.xml: ``` +### Hardened runtime and the Mac App Store + +By default the bridge extracts the `libjcocoa.dylib` inside its jar to a temporary file and loads it from there. An app signed with the hardened runtime (as notarized and Mac App Store apps are) can only load libraries signed by its own team, unless it has the `com.apple.security.cs.disable-library-validation` entitlement, so it cannot load that extracted copy. + +Such an app should bundle `libjcocoa.dylib` itself (for example in `Contents/MacOS`), sign it with the rest of the app, and name it with a system property: + +``` +-Dca.weblite.objc.libjcocoa.path=/path/to/YourApp.app/Contents/MacOS/libjcocoa.dylib +``` + +With the property set, the bridge loads that file and never extracts its own. The `libjcocoa.dylib` in a release build of this jar is universal (arm64 and x86_64). + ## Examples * [Sample wrapper of NSOpenPanel and NSSavePanel](https://gist.github.com/shannah/65007754c2b0f8add4f7) diff --git a/src/main/java/ca/weblite/objc/RuntimeUtils.java b/src/main/java/ca/weblite/objc/RuntimeUtils.java index 1362599..e197ed0 100644 --- a/src/main/java/ca/weblite/objc/RuntimeUtils.java +++ b/src/main/java/ca/weblite/objc/RuntimeUtils.java @@ -1,6 +1,7 @@ package ca.weblite.objc; +import java.io.File; import java.io.IOException; import java.io.UncheckedIOException; import java.lang.reflect.Field; @@ -53,11 +54,47 @@ private RuntimeUtils() { } + /** + * System property naming a {@code libjcocoa.dylib} file to load instead of + * the copy inside this jar, e.g. + * {@code -Dca.weblite.objc.libjcocoa.path=$APP_ROOT/Contents/MacOS/libjcocoa.dylib}. + * + *

By default the bridge extracts the {@code libjcocoa.dylib} inside its jar + * to a temporary file and loads that. An app signed with the hardened runtime + * (as notarized and Mac App Store apps are) and without the + * {@code com.apple.security.cs.disable-library-validation} entitlement cannot + * load that copy: it is not signed by the app's team. Such an app bundles + * {@code libjcocoa.dylib}, signs it with the rest of the app, and names it with + * this property.

+ */ + public static final String LIBJCOCOA_PATH_PROPERTY = "ca.weblite.objc.libjcocoa.path"; + static { + loadLibjcocoa(System.getProperty(LIBJCOCOA_PATH_PROPERTY)); + init(); + } + + /** + * Loads libjcocoa from {@code path} when it is set, and from the copy inside + * this jar otherwise. + * + * @param path An absolute or relative path to a {@code libjcocoa.dylib}, or + * {@code null} or empty to use the copy inside this jar. + * @throws UnsatisfiedLinkError if {@code path} is set but is not a loadable file. + * @throws UncheckedIOException if the copy inside the jar cannot be extracted. + */ + static void loadLibjcocoa(String path) { + if (path != null && !path.isEmpty()) { + File library = new File(path); + if (!library.isFile()) { + throw new UnsatisfiedLinkError(LIBJCOCOA_PATH_PROPERTY + " is " + path + ", which is not a file"); + } + System.load(library.getAbsolutePath()); + return; + } String libraryPath = "/libjcocoa.dylib"; try { NativeUtils.loadLibraryFromJar(libraryPath); - init(); } catch (IOException ioException) { throw new UncheckedIOException("Failed loading library " + libraryPath, ioException); } diff --git a/src/test/java/ca/weblite/objc/LibjcocoaPathTest.java b/src/test/java/ca/weblite/objc/LibjcocoaPathTest.java new file mode 100644 index 0000000..1e0fba3 --- /dev/null +++ b/src/test/java/ca/weblite/objc/LibjcocoaPathTest.java @@ -0,0 +1,102 @@ +package ca.weblite.objc; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Which libjcocoa the bridge loads, with and without + * {@link RuntimeUtils#LIBJCOCOA_PATH_PROPERTY}. The library is loaded once per + * JVM, in RuntimeUtils' static initializer, so each case runs + * {@link LoadedLibjcocoa} in a JVM of its own. + */ +public class LibjcocoaPathTest { + + @TempDir + Path tempDir; + + @Test + public void loadsTheFileNamedByTheProperty() throws Exception { + // A copy in a directory of its own, so it cannot be mistaken for the + // file the default path extracts from the jar. + Path bundled = tempDir.resolve("bundled"); + Files.createDirectories(bundled); + Path library = Files.copy(builtLibjcocoa(), bundled.resolve("libjcocoa.dylib"), StandardCopyOption.REPLACE_EXISTING); + + List loaded = runLoadedLibjcocoa("-D" + RuntimeUtils.LIBJCOCOA_PATH_PROPERTY + "=" + library); + + assertEquals(List.of(canonical(library.toString())), loaded); + } + + @Test + public void extractsTheCopyInsideTheJarByDefault() throws Exception { + List loaded = runLoadedLibjcocoa(); + + assertEquals(1, loaded.size(), "loaded " + loaded); + String path = loaded.get(0); + assertNotEquals(canonical(builtLibjcocoa().toString()), path); + assertTrue(path.startsWith(canonical(System.getProperty("java.io.tmpdir"))), + path + " is not in java.io.tmpdir"); + } + + @Test + public void rejectsAPathThatIsNotAFile() { + String missing = tempDir.resolve("missing/libjcocoa.dylib").toString(); + UnsatisfiedLinkError error = assertThrows(UnsatisfiedLinkError.class, + () -> RuntimeUtils.loadLibjcocoa(missing)); + assertTrue(error.getMessage().contains(RuntimeUtils.LIBJCOCOA_PATH_PROPERTY), error.getMessage()); + assertTrue(error.getMessage().contains(missing), error.getMessage()); + } + + /** The libjcocoa.dylib the build put on the class path (target/classes). */ + private static Path builtLibjcocoa() throws URISyntaxException { + URL url = RuntimeUtils.class.getResource("/libjcocoa.dylib"); + assertNotNull(url, "libjcocoa.dylib is not on the class path; build it with mvn"); + assertEquals("file", url.getProtocol(), "expected libjcocoa.dylib in a directory, not " + url); + return Path.of(url.toURI()); + } + + private static String canonical(String path) throws IOException { + return new File(path).getCanonicalPath(); + } + + /** Runs LoadedLibjcocoa in a new JVM and returns the canonical paths it reports. */ + private static List runLoadedLibjcocoa(String... jvmOptions) throws Exception { + List command = new ArrayList<>(); + command.add(Path.of(System.getProperty("java.home"), "bin", "java").toString()); + command.addAll(List.of(jvmOptions)); + command.add("-cp"); + command.add(System.getProperty("java.class.path")); + command.add(LoadedLibjcocoa.class.getName()); + + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + List output = new ArrayList<>(); + List loaded = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + output.add(line); + if (line.startsWith("loaded: ")) { + loaded.add(canonical(line.substring("loaded: ".length()))); + } + } + } + assertEquals(0, process.waitFor(), "LoadedLibjcocoa failed:\n" + String.join("\n", output)); + return loaded; + } +} diff --git a/src/test/java/ca/weblite/objc/LoadedLibjcocoa.java b/src/test/java/ca/weblite/objc/LoadedLibjcocoa.java new file mode 100644 index 0000000..007d4c4 --- /dev/null +++ b/src/test/java/ca/weblite/objc/LoadedLibjcocoa.java @@ -0,0 +1,31 @@ +package ca.weblite.objc; + +import com.sun.jna.Library; +import com.sun.jna.Native; + +/** + * Run in its own JVM by {@link LibjcocoaPathTest}: initializes the bridge, then + * prints the path of every libjcocoa image the process has loaded, one per + * line, prefixed with "loaded: ". + */ +public class LoadedLibjcocoa { + + interface Dyld extends Library { + int _dyld_image_count(); + String _dyld_get_image_name(int index); + } + + public static void main(String[] args) { + if (RuntimeUtils.cls("NSObject") == null) { + throw new IllegalStateException("bridge did not initialize"); + } + Dyld dyld = Native.load("System", Dyld.class); + int count = dyld._dyld_image_count(); + for (int i = 0; i < count; i++) { + String name = dyld._dyld_get_image_name(i); + if (name != null && name.contains("jcocoa")) { + System.out.println("loaded: " + name); + } + } + } +}