Skip to content

Commit 431d85e

Browse files
authored
Merge pull request #192 from kdroidFilter/feat/notification-common
feat: add notification-common cross-platform abstraction
2 parents 089a70d + 2749225 commit 431d85e

30 files changed

Lines changed: 1760 additions & 182 deletions

File tree

core-runtime/src/main/kotlin/io/github/kdroidfilter/nucleus/core/runtime/NativeLibraryLoader.kt

Lines changed: 65 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.github.kdroidfilter.nucleus.core.runtime
22

3+
import java.net.JarURLConnection
34
import java.nio.file.Files
45
import java.nio.file.Path
56
import 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
*/
1820
object 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 {

core-runtime/src/main/kotlin/io/github/kdroidfilter/nucleus/core/runtime/NucleusApp.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@ public object NucleusApp {
1818
private const val PROP_APP_VERSION = "nucleus.app.version"
1919
private const val PROP_APP_VENDOR = "nucleus.app.vendor"
2020
private const val PROP_APP_DESCRIPTION = "nucleus.app.description"
21+
private const val PROP_APP_NAME = "nucleus.app.name"
22+
private const val PROP_APP_AUMID = "nucleus.app.aumid"
2123

2224
private const val RES_APP_ID = "app.id"
2325
private const val RES_APP_VERSION = "app.version"
2426
private const val RES_APP_VENDOR = "app.vendor"
2527
private const val RES_APP_DESCRIPTION = "app.description"
28+
private const val RES_APP_NAME = "app.name"
29+
private const val RES_APP_AUMID = "app.aumid"
2630

2731
private val resourceProps: Properties? by lazy { loadResource() }
2832

@@ -53,6 +57,23 @@ public object NucleusApp {
5357
resolve(PROP_APP_DESCRIPTION, RES_APP_DESCRIPTION)
5458
}
5559

60+
/** The application display name (e.g. "Nucleus Demo"), or `null` if not configured. */
61+
@JvmStatic
62+
public val appName: String? by lazy {
63+
resolve(PROP_APP_NAME, RES_APP_NAME)
64+
}
65+
66+
/**
67+
* The Windows Application User Model ID (AUMID).
68+
* Matches the electron-builder `appId` (e.g. "com.app.NucleusDemo").
69+
* Used for toast notifications, badge updates, and jump lists.
70+
* Falls back to [appId] if not explicitly configured.
71+
*/
72+
@JvmStatic
73+
public val aumid: String by lazy {
74+
resolve(PROP_APP_AUMID, RES_APP_AUMID) ?: appId
75+
}
76+
5677
/** `true` if the Nucleus plugin injected metadata (via system property or classpath resource). */
5778
@JvmStatic
5879
public val isConfigured: Boolean by lazy {

0 commit comments

Comments
 (0)