11package io.github.kdroidfilter.nucleus.core.runtime
22
3+ import java.net.JarURLConnection
34import java.nio.file.Files
45import java.nio.file.Path
56import java.nio.file.StandardCopyOption
@@ -13,7 +14,8 @@ import java.util.logging.Logger
1314 * (`~/.cache/nucleus/native/` on macOS/Linux, `%LOCALAPPDATA%/nucleus/native/` on Windows)
1415 * so that subsequent launches skip the extraction I/O entirely.
1516 *
16- * The cache is invalidated per-library when the JAR resource size changes.
17+ * The cache is invalidated per-library using a fingerprint derived from the
18+ * JAR entry CRC-32 and size (read from ZIP headers — zero I/O cost).
1719 */
1820object NativeLibraryLoader {
1921 private val logger = Logger .getLogger(NativeLibraryLoader ::class .java.simpleName)
@@ -64,36 +66,51 @@ object NativeLibraryLoader {
6466 val fileName = mapLibraryFileName(libraryName, platform)
6567 val resourcePath = " $resourcePrefix /${platform.resourceDir} /$fileName "
6668
67- val stream =
68- callerClass.getResourceAsStream (resourcePath) ? : run {
69+ val resourceUrl =
70+ callerClass.getResource (resourcePath) ? : run {
6971 logger.fine(" Native library not available on this platform: $resourcePath " )
7072 return false
7173 }
7274
73- val cachedLib =
74- stream.use { input ->
75- val cacheDir = resolveCacheDir().resolve(platform.resourceDir)
76- Files .createDirectories(cacheDir)
77- val target = cacheDir.resolve(fileName)
75+ // Read fingerprint from JAR entry metadata (CRC-32 + size from ZIP header, no I/O)
76+ val fingerprint = resolveFingerprint(resourceUrl)
7877
79- val resourceSize = input.available().toLong()
80- if (Files .exists(target) && isCacheValid(target, resourceSize)) {
81- return @use target
82- }
78+ val cacheDir = resolveCacheDir().resolve(platform.resourceDir)
79+ Files .createDirectories(cacheDir)
80+ val target = cacheDir.resolve(fileName)
81+ val fingerprintFile = cacheDir.resolve(" $fileName .fingerprint" )
82+
83+ if (Files .exists(target) && isCacheValid(fingerprintFile, fingerprint)) {
84+ System .load(target.toAbsolutePath().toString())
85+ loadedLibraries + = libraryName
86+ return true
87+ }
88+
89+ // Cache miss — extract from JAR into a temp file
90+ val tmp = Files .createTempFile(cacheDir, libraryName, " .tmp" )
91+ resourceUrl.openStream().use { input ->
92+ Files .copy(input, tmp, StandardCopyOption .REPLACE_EXISTING )
93+ }
8394
84- // Write to a temp file first, then atomically move to avoid partial reads
85- val tmp = Files .createTempFile(cacheDir, libraryName, " .tmp" )
95+ // Try to move into the canonical cache location.
96+ // On Windows the target may be locked by another process that loaded
97+ // the previous version — in that case, load directly from the temp file.
98+ val loadPath =
99+ try {
86100 try {
87- Files .copy(input, tmp, StandardCopyOption .REPLACE_EXISTING )
88101 Files .move(tmp, target, StandardCopyOption .REPLACE_EXISTING , StandardCopyOption .ATOMIC_MOVE )
89102 } catch (_: Exception ) {
90- // ATOMIC_MOVE not supported on all filesystems
91103 Files .move(tmp, target, StandardCopyOption .REPLACE_EXISTING )
92104 }
105+ writeFingerprint(fingerprintFile, fingerprint)
93106 target
107+ } catch (_: Exception ) {
108+ // Target locked — load from temp, clean up on next launch
109+ logger.fine(" Cache file locked, loading from temp: $tmp " )
110+ tmp
94111 }
95112
96- System .load(cachedLib .toAbsolutePath().toString())
113+ System .load(loadPath .toAbsolutePath().toString())
97114 loadedLibraries + = libraryName
98115 return true
99116 } catch (e: Exception ) {
@@ -102,17 +119,41 @@ object NativeLibraryLoader {
102119 }
103120 }
104121
122+ /* *
123+ * Builds a fingerprint string from JAR entry metadata.
124+ * For `jar:` URLs the CRC-32 and size come straight from the ZIP central directory.
125+ * For `file:` URLs (IDE dev mode) we use file size and last-modified timestamp.
126+ */
127+ private fun resolveFingerprint (resourceUrl : java.net.URL ): String {
128+ val connection = resourceUrl.openConnection()
129+ if (connection is JarURLConnection ) {
130+ val entry = connection.jarEntry
131+ return " ${entry.crc} :${entry.size} "
132+ }
133+ // file: URL fallback (running from IDE classes dir)
134+ return " ${connection.contentLengthLong} :${connection.lastModified} "
135+ }
136+
105137 private fun isCacheValid (
106- cachedFile : Path ,
107- resourceSize : Long ,
108- ): Boolean {
109- // If the resource stream doesn't report size (available() == 0), skip validation
110- if (resourceSize <= 0 ) return true
111- return try {
112- Files .size(cachedFile) == resourceSize
138+ fingerprintFile : Path ,
139+ currentFingerprint : String ,
140+ ): Boolean =
141+ try {
142+ Files .exists(fingerprintFile) &&
143+ Files .readString(fingerprintFile).trim() == currentFingerprint
113144 } catch (_: Exception ) {
114145 false
115146 }
147+
148+ private fun writeFingerprint (
149+ fingerprintFile : Path ,
150+ fingerprint : String ,
151+ ) {
152+ try {
153+ Files .writeString(fingerprintFile, fingerprint)
154+ } catch (_: Exception ) {
155+ // Non-critical — worst case we re-extract next time
156+ }
116157 }
117158
118159 private fun resolveCacheDir (): Path {
0 commit comments