diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml
index d539874de0a982e3d650d4d74c8d88251a07105c..ba100da3888e4d506badeca4dffa0501b07d96c7 100644
--- a/.github/workflows/checks.yaml
+++ b/.github/workflows/checks.yaml
@@ -31,3 +31,15 @@ jobs:
         java-version: '17'
     - name: Run Gradle tests
       run: ./gradlew check --info --stacktrace
+  build-macos:
+    runs-on: macos-latest
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v3
+    - name: Setup Java
+      uses: actions/setup-java@v3
+      with:
+        distribution: 'temurin'
+        java-version: '17'
+    - name: Run Gradle tests
+      run: ./gradlew check --info --stacktrace
diff --git a/onionwrapper-core/src/main/java/org/briarproject/onionwrapper/AbstractTorWrapper.java b/onionwrapper-core/src/main/java/org/briarproject/onionwrapper/AbstractTorWrapper.java
index b2dbb22f555fa8efd1a887de6c873d59fc7d3d07..3916c221a73ffbbe79bfc5f34ff0690a5b07b639 100644
--- a/onionwrapper-core/src/main/java/org/briarproject/onionwrapper/AbstractTorWrapper.java
+++ b/onionwrapper-core/src/main/java/org/briarproject/onionwrapper/AbstractTorWrapper.java
@@ -72,8 +72,9 @@ abstract class AbstractTorWrapper implements EventHandler, TorWrapper {
 
 	protected final Executor ioExecutor;
 	protected final Executor eventExecutor;
-	private final String architecture;
-	private final File torDirectory, configFile, doneFile, cookieFile;
+	protected final String architecture;
+	protected final File torDirectory;
+	private final File configFile, doneFile, cookieFile;
 	private final int torSocksPort;
 	private final int torControlPort;
 
@@ -243,7 +244,7 @@ abstract class AbstractTorWrapper implements EventHandler, TorWrapper {
 		if (!snowflakeFile.setExecutable(true, true)) throw new IOException();
 	}
 
-	private InputStream getExecutableInputStream(String basename) {
+	protected InputStream getExecutableInputStream(String basename) {
 		String ext = getExecutableExtension();
 		return requireNonNull(getResourceInputStream(architecture + "/" + basename, ext));
 	}
diff --git a/onionwrapper-core/src/main/java/org/briarproject/onionwrapper/util/OsUtils.java b/onionwrapper-core/src/main/java/org/briarproject/onionwrapper/util/OsUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..67373dd98d64c998410f3a10ff1ca3aa8b27bfb6
--- /dev/null
+++ b/onionwrapper-core/src/main/java/org/briarproject/onionwrapper/util/OsUtils.java
@@ -0,0 +1,32 @@
+package org.briarproject.onionwrapper.util;
+
+import org.briarproject.nullsafety.NotNullByDefault;
+
+import javax.annotation.Nullable;
+
+import static org.briarproject.onionwrapper.util.StringUtils.startsWithIgnoreCase;
+
+@NotNullByDefault
+public class OsUtils {
+
+	@Nullable
+	private static final String os = System.getProperty("os.name");
+	@Nullable
+	private static final String vendor = System.getProperty("java.vendor");
+
+	public static boolean isWindows() {
+		return os != null && startsWithIgnoreCase(os, "Win");
+	}
+
+	public static boolean isMac() {
+		return os != null && os.equalsIgnoreCase("Mac OS X");
+	}
+
+	public static boolean isLinux() {
+		return os != null && startsWithIgnoreCase(os, "Linux") && !isAndroid();
+	}
+
+	public static boolean isAndroid() {
+		return vendor != null && vendor.contains("Android");
+	}
+}
diff --git a/onionwrapper-core/src/main/java/org/briarproject/onionwrapper/util/StringUtils.java b/onionwrapper-core/src/main/java/org/briarproject/onionwrapper/util/StringUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..8964d41ac5651579eeb80de976ca5d7cd4a0b99d
--- /dev/null
+++ b/onionwrapper-core/src/main/java/org/briarproject/onionwrapper/util/StringUtils.java
@@ -0,0 +1,12 @@
+package org.briarproject.onionwrapper.util;
+
+import org.briarproject.nullsafety.NotNullByDefault;
+
+@NotNullByDefault
+public class StringUtils {
+
+	// see https://stackoverflow.com/a/38947571
+	static boolean startsWithIgnoreCase(String s, String prefix) {
+		return s.regionMatches(true, 0, prefix, 0, prefix.length());
+	}
+}
diff --git a/onionwrapper-core/src/test/java/org/briarproject/onionwrapper/TestUtils.java b/onionwrapper-core/src/test/java/org/briarproject/onionwrapper/TestUtils.java
index 2c6b65e1e62a466edef7e5223ce8691d7983f1a5..9c7d1de34473a8cc1a1c6392c53bd0cf7aeedd94 100644
--- a/onionwrapper-core/src/test/java/org/briarproject/onionwrapper/TestUtils.java
+++ b/onionwrapper-core/src/test/java/org/briarproject/onionwrapper/TestUtils.java
@@ -12,7 +12,9 @@ import javax.annotation.concurrent.ThreadSafe;
 import static java.util.Arrays.asList;
 import static java.util.logging.Level.WARNING;
 import static java.util.logging.Logger.getLogger;
-import static org.briarproject.onionwrapper.StringUtils.startsWithIgnoreCase;
+import static org.briarproject.onionwrapper.util.OsUtils.isLinux;
+import static org.briarproject.onionwrapper.util.OsUtils.isMac;
+import static org.briarproject.onionwrapper.util.OsUtils.isWindows;
 
 @ThreadSafe
 @NotNullByDefault
@@ -54,29 +56,21 @@ public class TestUtils {
 		}
 	}
 
-	public static boolean isLinux() {
-		String os = System.getProperty("os.name");
-		return os != null && os.contains("Linux");
-	}
-
-	public static boolean isWindows() {
-		String os = System.getProperty("os.name");
-		return os != null && startsWithIgnoreCase(os, "Win");
-	}
-
-	public static boolean isMac() {
-		String os = System.getProperty("os.name");
-		return os != null && os.equalsIgnoreCase("Mac OS X");
-	}
-
 	@Nullable
 	public static String getArchitectureForTorBinary() {
 		String arch = System.getProperty("os.arch");
 		if (arch == null) return null;
-		//noinspection IfCanBeSwitch
-		if (arch.equals("amd64")) return "x86_64";
-		else if (arch.equals("aarch64")) return "aarch64";
-		else if (arch.equals("arm")) return "armhf";
+		if (isLinux()) {
+			//noinspection IfCanBeSwitch
+			if (arch.equals("amd64")) return "x86_64";
+			else if (arch.equals("aarch64")) return "aarch64";
+			else if (arch.equals("arm")) return "armhf";
+		} else if (isWindows()) {
+			if (arch.equals("amd64")) return "x86_64";
+		} else if (isMac()) {
+			if (arch.equals("amd64")) return "x86_64";
+			else if (arch.equals("aarch64")) return "aarch64";
+		}
 		return null;
 	}
 
diff --git a/onionwrapper-java/build.gradle b/onionwrapper-java/build.gradle
index 913f000b25e6e8ecdefdbca470d2a10acf790042..5d742636a2c8de9c021c2ca8c4652b3b73b13132 100644
--- a/onionwrapper-java/build.gradle
+++ b/onionwrapper-java/build.gradle
@@ -1,5 +1,3 @@
-import static org.briarproject.onionwrapper.OS.Linux
-import static org.briarproject.onionwrapper.OS.Windows
 import static org.briarproject.onionwrapper.OsUtils.currentOS
 
 plugins {
@@ -19,17 +17,15 @@ checkstyle {
 
 dependencies {
     api project(':onionwrapper-core')
-    def jna_version = '4.5.2'
+    def jna_version = '5.13.0'
     implementation "net.java.dev.jna:jna:$jna_version"
     implementation "net.java.dev.jna:jna-platform:$jna_version"
 
     testImplementation project(path: ':onionwrapper-core', configuration: 'testOutput')
     testImplementation 'junit:junit:4.13.2'
-    if (currentOS == Linux || currentOS == Windows) {
-        testImplementation "org.briarproject:tor-$currentOS.id:0.4.7.13-2"
-        testImplementation "org.briarproject:obfs4proxy-$currentOS.id:0.0.14-tor2"
-        testImplementation "org.briarproject:snowflake-$currentOS.id:2.5.1"
-    }
+    testImplementation "org.briarproject:tor-$currentOS.id:0.4.7.13-2"
+    testImplementation "org.briarproject:obfs4proxy-$currentOS.id:0.0.14-tor2"
+    testImplementation "org.briarproject:snowflake-$currentOS.id:2.5.1"
 }
 
 mavenPublishing {
diff --git a/onionwrapper-java/src/main/java/org/briarproject/onionwrapper/MacTorWrapper.java b/onionwrapper-java/src/main/java/org/briarproject/onionwrapper/MacTorWrapper.java
new file mode 100644
index 0000000000000000000000000000000000000000..48fe817da0fa4493d658b3b9491f36d2d2ba9e23
--- /dev/null
+++ b/onionwrapper-java/src/main/java/org/briarproject/onionwrapper/MacTorWrapper.java
@@ -0,0 +1,68 @@
+package org.briarproject.onionwrapper;
+
+import org.briarproject.nullsafety.NotNullByDefault;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.concurrent.Executor;
+
+import static java.util.logging.Level.INFO;
+
+@NotNullByDefault
+public class MacTorWrapper extends UnixTorWrapper {
+
+	static final String LIB_EVENT_VERSION = "2.1.7";
+
+	/**
+	 * @param ioExecutor The wrapper will use this executor to run IO tasks,
+	 * 		some of which may run for the lifetime of the wrapper, so the executor
+	 * 		should have an unlimited thread pool.
+	 * @param eventExecutor The wrapper will use this executor to call the
+	 *        {@link Observer observer} (if any). To ensure that events are observed
+	 * 		in the order they occur, this executor should have a single thread (eg
+	 * 		the app's main thread).
+	 * @param architecture The processor architecture of the Tor and pluggable
+	 * 		transport binaries.
+	 * @param torDirectory The directory where the Tor process should keep its
+	 * 		state.
+	 * @param torSocksPort The port number to use for Tor's SOCKS port.
+	 * @param torControlPort The port number to use for Tor's control port.
+	 */
+	public MacTorWrapper(Executor ioExecutor,
+			Executor eventExecutor,
+			String architecture,
+			File torDirectory,
+			int torSocksPort,
+			int torControlPort) {
+		super(ioExecutor, eventExecutor, architecture, torDirectory, torSocksPort, torControlPort);
+	}
+
+	@Override
+	protected void installTorExecutable() throws IOException {
+		super.installTorExecutable();
+		installLibEvent();
+	}
+
+	private void installLibEvent() throws IOException {
+		if (LOG.isLoggable(INFO)) {
+			LOG.info("Installing libevent binary for " + architecture);
+		}
+		File libEventFile = getLibEventFile();
+		extract(getExecutableInputStream("libevent-" + LIB_EVENT_VERSION + ".dylib"),
+				libEventFile);
+	}
+
+	private File getLibEventFile() {
+		return new File(torDirectory, "libevent-" + LIB_EVENT_VERSION + ".dylib");
+	}
+
+	@Override
+	protected void extract(InputStream in, File dest) throws IOException {
+		// Important: delete file to prevent problems on macOS in case the file signature changed
+		// for binaries.
+		//noinspection ResultOfMethodCallIgnored
+		dest.delete();
+		super.extract(in, dest);
+	}
+}
diff --git a/onionwrapper-java/src/main/java/org/briarproject/onionwrapper/UnixTorWrapper.java b/onionwrapper-java/src/main/java/org/briarproject/onionwrapper/UnixTorWrapper.java
index 3d196f98b70d07ae31bb81628d6644121fceea8d..eaea8158d4a93892141cf84186f37ae788d2a190 100644
--- a/onionwrapper-java/src/main/java/org/briarproject/onionwrapper/UnixTorWrapper.java
+++ b/onionwrapper-java/src/main/java/org/briarproject/onionwrapper/UnixTorWrapper.java
@@ -46,7 +46,7 @@ public class UnixTorWrapper extends JavaTorWrapper {
 
 	private interface CLibrary extends Library {
 
-		CLibrary INSTANCE = Native.loadLibrary("c", CLibrary.class);
+		CLibrary INSTANCE = Native.load("c", CLibrary.class);
 
 		int getpid();
 	}
diff --git a/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/BootstrapTest.java b/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/BootstrapTest.java
index 63d56d87dd83fa4d5a5c0f2a7490d6986ce97625..98bb14658c83423ecb0fa4c54a27e50b7903de8c 100644
--- a/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/BootstrapTest.java
+++ b/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/BootstrapTest.java
@@ -15,11 +15,12 @@ import static org.briarproject.nullsafety.NullSafety.requireNonNull;
 import static org.briarproject.onionwrapper.TestUtils.deleteTestDirectory;
 import static org.briarproject.onionwrapper.TestUtils.getArchitectureForTorBinary;
 import static org.briarproject.onionwrapper.TestUtils.getTestDirectory;
-import static org.briarproject.onionwrapper.TestUtils.isLinux;
-import static org.briarproject.onionwrapper.TestUtils.isWindows;
 import static org.briarproject.onionwrapper.TorWrapper.TorState.CONNECTED;
 import static org.briarproject.onionwrapper.TorWrapper.TorState.STARTED;
 import static org.briarproject.onionwrapper.TorWrapper.TorState.STOPPED;
+import static org.briarproject.onionwrapper.util.OsUtils.isLinux;
+import static org.briarproject.onionwrapper.util.OsUtils.isMac;
+import static org.briarproject.onionwrapper.util.OsUtils.isWindows;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assume.assumeNotNull;
@@ -38,7 +39,7 @@ public class BootstrapTest extends BaseTest {
 
 	@Before
 	public void setUp() {
-		assumeTrue(isLinux() || isWindows());
+		assumeTrue(isLinux() || isWindows() || isMac());
 		assumeNotNull(getArchitectureForTorBinary());
 	}
 
@@ -55,6 +56,9 @@ public class BootstrapTest extends BaseTest {
 		if (isLinux()) {
 			tor = new UnixTorWrapper(executor, executor, architecture, torDir,
 					CONTROL_PORT, SOCKS_PORT);
+		} else if (isMac()) {
+			tor = new MacTorWrapper(executor, executor, architecture, torDir,
+					CONTROL_PORT, SOCKS_PORT);
 		} else if (isWindows()) {
 			tor = new WindowsTorWrapper(executor, executor, architecture, torDir,
 					CONTROL_PORT, SOCKS_PORT);
diff --git a/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/BridgeTest.java b/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/BridgeTest.java
index 1c822f53e62ae7853ae2860b682deaaa03c99000..5401107e5e8f46c82cc2e9562e0db4bba43bf8b8 100644
--- a/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/BridgeTest.java
+++ b/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/BridgeTest.java
@@ -32,9 +32,9 @@ import static org.briarproject.onionwrapper.CircumventionProvider.BridgeType.VAN
 import static org.briarproject.onionwrapper.TestUtils.deleteTestDirectory;
 import static org.briarproject.onionwrapper.TestUtils.getArchitectureForTorBinary;
 import static org.briarproject.onionwrapper.TestUtils.getTestDirectory;
-import static org.briarproject.onionwrapper.TestUtils.isLinux;
 import static org.briarproject.onionwrapper.TestUtils.isOptionalTestEnabled;
 import static org.briarproject.onionwrapper.TorWrapper.TorState.CONNECTED;
+import static org.briarproject.onionwrapper.util.OsUtils.isLinux;
 import static org.junit.Assert.fail;
 import static org.junit.Assume.assumeNotNull;
 import static org.junit.Assume.assumeTrue;
diff --git a/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/ResourcesLinuxTest.java b/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/ResourcesLinuxTest.java
index 7ebbf50a754d7c948e64041bb33dc630f005f6fb..666f627bd3da974793117229a0feb98de208d195 100644
--- a/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/ResourcesLinuxTest.java
+++ b/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/ResourcesLinuxTest.java
@@ -3,7 +3,7 @@ package org.briarproject.onionwrapper;
 import org.junit.Before;
 import org.junit.Test;
 
-import static org.briarproject.onionwrapper.TestUtils.isLinux;
+import static org.briarproject.onionwrapper.util.OsUtils.isLinux;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assume.assumeTrue;
 
diff --git a/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/ResourcesMacTest.java b/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/ResourcesMacTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..213f2f62020e71fa57da523b6e37e97a3f3c4d84
--- /dev/null
+++ b/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/ResourcesMacTest.java
@@ -0,0 +1,43 @@
+package org.briarproject.onionwrapper;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.briarproject.onionwrapper.MacTorWrapper.LIB_EVENT_VERSION;
+import static org.briarproject.onionwrapper.util.OsUtils.isMac;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assume.assumeTrue;
+
+public class ResourcesMacTest {
+
+	@Before
+	public void setUp() {
+		assumeTrue(isMac());
+	}
+
+	@Test
+	public void testCanLoadTor() {
+		testCanLoadResource("x86_64/tor");
+	}
+
+	@Test
+	public void testCanLoadLibEvent() {
+		testCanLoadResource("x86_64/libevent-" + LIB_EVENT_VERSION + ".dylib");
+	}
+
+	@Test
+	public void testCanLoadObfs4() {
+		testCanLoadResource("x86_64/obfs4proxy");
+	}
+
+	@Test
+	public void testCanLoadSnowflake() {
+		testCanLoadResource("x86_64/snowflake");
+	}
+
+	private void testCanLoadResource(String name) {
+		ClassLoader classLoader =
+				Thread.currentThread().getContextClassLoader();
+		assertNotNull(classLoader.getResourceAsStream(name));
+	}
+}
diff --git a/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/ResourcesWindowsTest.java b/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/ResourcesWindowsTest.java
index 01a013786170089f6af09618edeb3e3c63c38fa6..51c8ddfac2c66035f88679196fec79b9d5439641 100644
--- a/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/ResourcesWindowsTest.java
+++ b/onionwrapper-java/src/test/java/org/briarproject/onionwrapper/ResourcesWindowsTest.java
@@ -3,7 +3,7 @@ package org.briarproject.onionwrapper;
 import org.junit.Before;
 import org.junit.Test;
 
-import static org.briarproject.onionwrapper.TestUtils.isWindows;
+import static org.briarproject.onionwrapper.util.OsUtils.isWindows;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assume.assumeTrue;