+ * {@value #NATIVE_DIR_PROPERTY} wins if set, so an application whose home directory is redirected or read-only, + * as under Flatpak, Snap or the macOS App Sandbox, can place the library itself. Otherwise this is the platform's + * cache location: extracted libraries are regenerable, and a version-scoped path there is shared by every + * application using this binding rather than duplicated per process. Windows uses LOCALAPPDATA rather than + * APPDATA because a native binary for one architecture must not roam to another machine. + */ + static Path extractionDir(String os, String arch) { + String override = System.getProperty(NATIVE_DIR_PROPERTY); + if (override != null && !override.isBlank()) { + return Path.of(override); } - String arch; - if (osArch.contains("aarch64") || osArch.contains("arm64")) { - arch = "aarch64"; - } else if (osArch.contains("amd64") || osArch.contains("x86_64") || osArch.contains("x64")) { - arch = "amd64"; + String home = System.getProperty("user.home", "."); + Path cacheRoot; + if ("windows".equals(os)) { + String localAppData = System.getenv("LOCALAPPDATA"); + cacheRoot = (localAppData != null && !localAppData.isBlank()) + ? Path.of(localAppData) + : Path.of(home, "AppData", "Local"); + } else if ("mac".equals(os)) { + cacheRoot = Path.of(home, "Library", "Caches"); } else { - arch = osArch; + String xdgCache = System.getenv("XDG_CACHE_HOME"); + cacheRoot = (xdgCache != null && !xdgCache.isBlank()) + ? Path.of(xdgCache) + : Path.of(home, ".cache"); } + return cacheRoot.resolve("prism-java").resolve(PrismBuildInfo.PRISM_VERSION).resolve(os + "-" + arch); + } + + private static void loadInternal() { + String osName = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + String osArch = System.getProperty("os.arch", "").toLowerCase(Locale.ROOT); + + String os = osName.contains("win") ? "windows" + : (osName.contains("mac") || osName.contains("darwin")) ? "mac" + : (osName.contains("linux") || osName.contains("unix") || osName.contains("sunos")) ? "linux" + : osName; + + String arch = (osArch.contains("aarch64") || osArch.contains("arm64")) ? "aarch64" + : (osArch.contains("amd64") || osArch.contains("x86_64") || osArch.contains("x64")) ? "amd64" + : osArch; + + String fileName = System.mapLibraryName("prism"); + String resourcePath = "mac".equals(os) + ? "/natives/mac/" + fileName + : "/natives/" + os + "/" + arch + "/" + fileName; try { - if ("windows".equals(os)) { - loadWindows(arch); - } else if ("linux".equals(os)) { - loadLinux(arch); - } else if ("mac".equals(os)) { - loadMac(); - } else { - System.loadLibrary("prism"); + URL resource = NativeLoader.class.getResource(resourcePath); + if (resource == null) { + throw new NoSuchFileException("Resource not found in classpath: " + resourcePath); } - } catch (Throwable t) { - try { - System.loadLibrary("prism"); - } catch (Throwable fallbackError) { - t.addSuppressed(fallbackError); - throw new UnsatisfiedLinkError("Failed to load PRISM native library: " + t.getMessage()); + + if ("file".equals(resource.getProtocol())) { + System.load(Path.of(resource.toURI()).toAbsolutePath().toString()); + return; } - } - } - private static void loadWindows(String arch) throws IOException { - Path tempDir = getTempDir(); - String resourceDir = "/natives/windows/" + arch + "/"; - Path prismPath = extractResource(resourceDir + "prism.dll", tempDir.resolve("prism.dll")); - if (prismPath != null) { - System.load(prismPath.toAbsolutePath().toString()); - } else { - System.loadLibrary("prism"); - } - } + Path dir = extractionDir(os, arch); + Path target = dir.resolve(fileName); - private static void loadLinux(String arch) throws IOException { - Path tempDir = getTempDir(); - String resourceDir = "/natives/linux/" + arch + "/"; - Path prismPath = extractResource(resourceDir + "libprism.so", tempDir.resolve("libprism.so")); - if (prismPath != null) { - System.load(prismPath.toAbsolutePath().toString()); - } else { - System.loadLibrary("prism"); - } - } + if (!Files.exists(target)) { + Files.createDirectories(dir); + Path tmp = Files.createTempFile(dir, fileName, ".tmp"); - private static void loadMac() throws IOException { - Path tempDir = getTempDir(); - Path prismPath = extractResource("/natives/mac/libprism.dylib", tempDir.resolve("libprism.dylib")); - if (prismPath != null) { - System.load(prismPath.toAbsolutePath().toString()); - } else { - System.loadLibrary("prism"); - } - } + // Use resource.openStream() so we don't have to query the classpath a second time + try (InputStream in = resource.openStream()) { + Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING); - private static Path extractResource(String resourcePath, Path target) throws IOException { - try (InputStream in = NativeLoader.class.getResourceAsStream(resourcePath)) { - if (in == null) { - return null; + try { + Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + // Fallback to non-atomic move + Files.move(tmp, target); + } + } catch (FileAlreadyExistsException e) { + // Another JVM process won the race and created the file concurrently. Safe to ignore. + } finally { + Files.deleteIfExists(tmp); + } + } + System.load(target.toAbsolutePath().toString()); + } catch (Throwable extractError) { + // Fallback to system library path if resource extraction failed or wasn't found + try { + System.loadLibrary("prism"); + } catch (Throwable fallbackError) { + UnsatisfiedLinkError error = new UnsatisfiedLinkError( + "Failed to load PRISM native library. Extraction error: " + extractError.getMessage()); + error.initCause(extractError); // Preserves original extraction stack trace + error.addSuppressed(fallbackError); + throw error; } - Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); - return target; } } - - private static Path getTempDir() throws IOException { - Path tempDir = Files.createTempDirectory("prism_native_"); - tempDir.toFile().deleteOnExit(); - return tempDir; - } } diff --git a/src/test/java/org/mcaccess/prism/natives/NativeLoaderTest.java b/src/test/java/org/mcaccess/prism/natives/NativeLoaderTest.java new file mode 100644 index 0000000..9602ff5 --- /dev/null +++ b/src/test/java/org/mcaccess/prism/natives/NativeLoaderTest.java @@ -0,0 +1,74 @@ +package org.mcaccess.prism.natives; + +import java.nio.file.Path; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class NativeLoaderTest { + + @AfterEach + void clearOverride() { + System.clearProperty(NativeLoader.NATIVE_DIR_PROPERTY); + } + + @Test + void theOverridePropertyWins() { + System.setProperty(NativeLoader.NATIVE_DIR_PROPERTY, Path.of("build", "custom").toString()); + assertThat(NativeLoader.extractionDir("windows", "amd64")).isEqualTo(Path.of("build", "custom")); + assertThat(NativeLoader.extractionDir("linux", "aarch64")).isEqualTo(Path.of("build", "custom")); + assertThat(NativeLoader.extractionDir("mac", "aarch64")).isEqualTo(Path.of("build", "custom")); + } + + @Test + void aBlankOverrideIsIgnored() { + System.setProperty(NativeLoader.NATIVE_DIR_PROPERTY, " "); + assertThat(NativeLoader.extractionDir("windows", "amd64")) + .endsWithRaw(Path.of("prism-java", PrismBuildInfo.PRISM_VERSION, "windows-amd64")); + } + + @Test + void theDefaultIsVersionAndPlatformScoped() { + assertThat(NativeLoader.extractionDir("windows", "amd64")) + .endsWithRaw(Path.of("prism-java", PrismBuildInfo.PRISM_VERSION, "windows-amd64")); + assertThat(NativeLoader.extractionDir("linux", "aarch64")) + .endsWithRaw(Path.of("prism-java", PrismBuildInfo.PRISM_VERSION, "linux-aarch64")); + assertThat(NativeLoader.extractionDir("mac", "aarch64")) + .endsWithRaw(Path.of("prism-java", PrismBuildInfo.PRISM_VERSION, "mac-aarch64")); + } + + @Test + void eachPlatformResolvesUnderItsOwnCacheRoot() { + Path home = Path.of(System.getProperty("user.home")); + assertThat(NativeLoader.extractionDir("mac", "aarch64")) + .startsWithRaw(home.resolve("Library").resolve("Caches")); + + Path linux = NativeLoader.extractionDir("linux", "amd64"); + String xdg = System.getenv("XDG_CACHE_HOME"); + assertThat(linux).startsWithRaw(xdg != null && !xdg.isBlank() ? Path.of(xdg) : home.resolve(".cache")); + + Path windows = NativeLoader.extractionDir("windows", "amd64"); + String localAppData = System.getenv("LOCALAPPDATA"); + assertThat(windows).startsWithRaw(localAppData != null && !localAppData.isBlank() + ? Path.of(localAppData) + : home.resolve("AppData").resolve("Local")); + } + + @Test + void platformsDoNotShareADirectory() { + assertThat(NativeLoader.extractionDir("windows", "amd64")) + .isNotEqualTo(NativeLoader.extractionDir("windows", "aarch64")) + .isNotEqualTo(NativeLoader.extractionDir("linux", "amd64")) + .isNotEqualTo(NativeLoader.extractionDir("mac", "amd64")); + assertThat(NativeLoader.extractionDir("mac", "amd64")) + .isNotEqualTo(NativeLoader.extractionDir("mac", "aarch64")); + } + + @Test + void theLibraryLoadedAndReportsNoFailure() { + NativeLoader.load(); + assertThat(NativeLoader.getLoadFailure()).isNull(); + } +}