Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 23 additions & 16 deletions .github/workflows/maven.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ Add the following dependency to your pom.xml:
</dependency>
```

### 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)
Expand Down
39 changes: 38 additions & 1 deletion src/main/java/ca/weblite/objc/RuntimeUtils.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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}.
*
* <p>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.</p>
*/
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);
}
Expand Down
102 changes: 102 additions & 0 deletions src/test/java/ca/weblite/objc/LibjcocoaPathTest.java
Original file line number Diff line number Diff line change
@@ -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<String> loaded = runLoadedLibjcocoa("-D" + RuntimeUtils.LIBJCOCOA_PATH_PROPERTY + "=" + library);

assertEquals(List.of(canonical(library.toString())), loaded);
}

@Test
public void extractsTheCopyInsideTheJarByDefault() throws Exception {
List<String> 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<String> runLoadedLibjcocoa(String... jvmOptions) throws Exception {
List<String> 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<String> output = new ArrayList<>();
List<String> 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;
}
}
31 changes: 31 additions & 0 deletions src/test/java/ca/weblite/objc/LoadedLibjcocoa.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
}
Loading