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