Skip to content

Commit 2749225

Browse files
committed
refactor(runtime): improve library caching and AUMID handling
- NativeLibraryLoader: optimize cache validation by switching to CRC-32-based fingerprints (no I/O during checks) - WindowsNotificationCenter: add ShortcutPolicy enum to control Start Menu shortcut behavior - NucleusApp: expose appName and aumid properties for better configuration handling - Native code: refactor shortcut creation, add policies for enhanced flexibility Notifications now offer finer control over shortcuts and enhanced performance for library loading.
1 parent 0d5ddd6 commit 2749225

15 files changed

Lines changed: 319 additions & 183 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 {

docs/runtime/notification-common.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ How each parameter maps to platform-specific APIs:
182182

183183
### Windows
184184

185+
- **Installed app required**: Notifications require a Start Menu shortcut (`.lnk`) with the AUMID property. This is created by the installer (e.g. `./gradlew packageDistributionForCurrentOS`). When running via `./gradlew run`, notifications work only if the app has been installed before (the shortcut already exists). A warning is logged otherwise.
185186
- **Initialization**: `WindowsNotificationCenter.initialize()` is called automatically on the first `send()`. Call `NotificationManager.initialize()` explicitly for early setup.
186187
- **Tag/Group**: Each notification gets a unique tag (`n1`, `n2`, ...) under the `"ncm"` group.
187188
- **Images**: `largeImage` maps to a hero image at the top of the toast. `smallIcon` maps to the app logo override (displayed left of the text). Both accept `file:///` URIs and HTTP URLs.

docs/runtime/notification-windows.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,16 @@ WindowsNotificationCenter.showSimple(
3333

3434
!!! tip "AUMID handling"
3535
- **APPX/MSIX**: the AUMID is resolved automatically from the package identity.
36-
- **Unpackaged apps (EXE, MSI, dev)**: the library derives the AUMID from `NucleusApp.appId`, registers it on the process via `SetCurrentProcessExplicitAppUserModelID`, and creates a Start Menu shortcut with the AUMID property.
36+
- **Unpackaged apps (EXE, MSI, dev)**: the library derives the AUMID from `NucleusApp.appId` and registers it on the process via `SetCurrentProcessExplicitAppUserModelID`.
3737
- You can also pass an explicit AUMID: `initialize(aumid = "MyCompany.MyApp")`.
3838

39+
!!! warning "Installed app required"
40+
Notifications on unpackaged apps require a Start Menu shortcut (`.lnk`) with the AUMID property set. Without it, toasts may not appear or persist in Action Center.
41+
42+
This shortcut is created by the installer (e.g. `./gradlew packageDistributionForCurrentOS`). When running via `./gradlew run`, notifications will work **only if the app has been installed before** (even a different version), since the shortcut already exists.
43+
44+
This is similar to macOS, where notifications require a packaged `.app` bundle.
45+
3946
## API Reference
4047

4148
### `WindowsNotificationCenter`
@@ -347,7 +354,7 @@ WindowsNotificationCenter.addListener(object : ToastNotificationListener {
347354

348355
Ships pre-built Windows DLLs (x64 + ARM64). No macOS or Linux native — `isAvailable` returns `false` on other platforms and all methods are no-op.
349356

350-
- `nucleus_notification_windows.dll` — linked against `ole32`, `runtimeobject`, `shell32`, `shlwapi`, `user32`, `advapi32`, `propsys`
357+
- `nucleus_notification_windows.dll` — linked against `ole32`, `runtimeobject`, `shell32`, `user32`, `advapi32`
351358
- Requires Windows 10 (build 10240+)
352359
- Headers and progress bars require Creators Update (build 15063+)
353360
- Uses WRL `Callback<>` for COM event handlers

example/src/main/kotlin/com/example/demo/Main.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ fun main(args: Array<String>) {
177177
) {
178178
// Set minimum window size (DPI-scaled automatically by JNI module)
179179
LaunchedEffect(Unit) {
180-
window.minimumSize = java.awt.Dimension(640, 480)
180+
window.minimumSize = java.awt.Dimension(1000, 480)
181181
}
182182
CompositionLocalProvider(
183183
LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr,

launcher-windows/src/main/kotlin/io/github/kdroidfilter/nucleus/launcher/windows/WindowsBadgeManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ object WindowsBadgeManager {
124124
): String {
125125
if (explicit != null) return explicit
126126
if (isAppx) return ""
127-
return NucleusApp.appId
127+
return NucleusApp.aumid
128128
}
129129

130130
private fun ensureReady(): Boolean {

launcher-windows/src/main/kotlin/io/github/kdroidfilter/nucleus/launcher/windows/WindowsJumpListManager.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ object WindowsJumpListManager {
4848
return false
4949
}
5050
if (ExecutableRuntime.isAppX()) return true
51-
val resolved = aumid ?: NucleusApp.appId
51+
val resolved = aumid ?: NucleusApp.aumid
5252
val error = NativeWindowsJumpListBridge.nativeSetProcessAppId(resolved)
5353
lastError = error
5454
if (error != null) {
@@ -165,7 +165,7 @@ object WindowsJumpListManager {
165165

166166
private fun resolveAumid(isAppx: Boolean): String {
167167
if (isAppx) return ""
168-
return NucleusApp.appId
168+
return NucleusApp.aumid
169169
}
170170

171171
// -1 = no icon (use app icon), 0 = stock, 1 = file, 2 = resource

launcher-windows/src/main/native/windows/nucleus_launcher_windows.cpp

Lines changed: 0 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -267,77 +267,6 @@ static HRESULT createSeparatorLink(IShellLinkW **ppLink) {
267267
return S_OK;
268268
}
269269

270-
// ============================================================================
271-
// Shortcut creation for unpackaged apps
272-
// ============================================================================
273-
274-
static HRESULT createShortcut(const std::wstring &aumid, const std::wstring &appName) {
275-
WCHAR exePath[MAX_PATH]{};
276-
GetModuleFileNameW(nullptr, exePath, MAX_PATH);
277-
278-
WCHAR appDataPath[MAX_PATH]{};
279-
DWORD written = GetEnvironmentVariableW(L"APPDATA", appDataPath, MAX_PATH);
280-
if (written == 0) return E_FAIL;
281-
282-
std::wstring lnkPath = std::wstring(appDataPath) +
283-
L"\\Microsoft\\Windows\\Start Menu\\Programs\\" + appName + L".lnk";
284-
285-
ComPtr<IShellLinkW> shellLink;
286-
HRESULT hr = CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER,
287-
IID_PPV_ARGS(&shellLink));
288-
if (FAILED(hr)) return hr;
289-
290-
ComPtr<IPersistFile> persistFile;
291-
hr = shellLink.As(&persistFile);
292-
if (FAILED(hr)) return hr;
293-
294-
bool needsCreate = true;
295-
if (SUCCEEDED(persistFile->Load(lnkPath.c_str(), STGM_READWRITE))) {
296-
ComPtr<IPropertyStore> propStore;
297-
if (SUCCEEDED(shellLink.As(&propStore))) {
298-
PROPVARIANT pv;
299-
PropVariantInit(&pv);
300-
if (SUCCEEDED(propStore->GetValue(PKEY_AppUserModel_ID, &pv))) {
301-
if (pv.vt == VT_LPWSTR && pv.pwszVal && aumid == pv.pwszVal) {
302-
needsCreate = false;
303-
}
304-
PropVariantClear(&pv);
305-
}
306-
}
307-
}
308-
309-
if (!needsCreate) return S_OK;
310-
311-
hr = shellLink->SetPath(exePath);
312-
if (FAILED(hr)) return hr;
313-
hr = shellLink->SetArguments(L"");
314-
if (FAILED(hr)) return hr;
315-
316-
std::wstring exeDir(exePath);
317-
size_t lastSlash = exeDir.find_last_of(L'\\');
318-
if (lastSlash != std::wstring::npos) exeDir = exeDir.substr(0, lastSlash);
319-
hr = shellLink->SetWorkingDirectory(exeDir.c_str());
320-
if (FAILED(hr)) return hr;
321-
322-
ComPtr<IPropertyStore> propStore;
323-
hr = shellLink.As(&propStore);
324-
if (FAILED(hr)) return hr;
325-
326-
PROPVARIANT appIdPropVar;
327-
hr = InitPropVariantFromString(aumid.c_str(), &appIdPropVar);
328-
if (FAILED(hr)) return hr;
329-
330-
hr = propStore->SetValue(PKEY_AppUserModel_ID, appIdPropVar);
331-
PropVariantClear(&appIdPropVar);
332-
if (FAILED(hr)) return hr;
333-
334-
hr = propStore->Commit();
335-
if (FAILED(hr)) return hr;
336-
337-
hr = persistFile->Save(lnkPath.c_str(), TRUE);
338-
return hr;
339-
}
340-
341270
// ============================================================================
342271
// Badge XML helper
343272
// ============================================================================
@@ -400,13 +329,6 @@ Java_io_github_kdroidfilter_nucleus_launcher_windows_NativeWindowsBadgeBridge_na
400329

401330
if (!g_isAppx && !g_aumid.empty()) {
402331
SetCurrentProcessExplicitAppUserModelID(g_aumid.c_str());
403-
404-
std::wstring appName = g_aumid;
405-
size_t lastDot = appName.find_last_of(L'.');
406-
if (lastDot != std::wstring::npos && lastDot + 1 < appName.length()) {
407-
appName = appName.substr(lastDot + 1);
408-
}
409-
createShortcut(g_aumid, appName);
410332
}
411333

412334
// Get BadgeUpdateManager
@@ -500,14 +422,6 @@ Java_io_github_kdroidfilter_nucleus_launcher_windows_NativeWindowsJumpListBridge
500422
hr = SetCurrentProcessExplicitAppUserModelID(aumid.c_str());
501423
if (FAILED(hr)) return errorString(env, "SetCurrentProcessExplicitAppUserModelID failed", hr);
502424

503-
// Create Start Menu shortcut with AUMID (required for unpackaged apps)
504-
std::wstring appName = aumid;
505-
size_t lastDot = appName.find_last_of(L'.');
506-
if (lastDot != std::wstring::npos && lastDot + 1 < appName.length()) {
507-
appName = appName.substr(lastDot + 1);
508-
}
509-
createShortcut(aumid, appName);
510-
511425
return nullptr;
512426
}
513427

@@ -535,14 +449,6 @@ Java_io_github_kdroidfilter_nucleus_launcher_windows_NativeWindowsJumpListBridge
535449
if (!isAppx && !aumid.empty()) {
536450
SetCurrentProcessExplicitAppUserModelID(aumid.c_str());
537451

538-
// Create Start Menu shortcut with AUMID (required for unpackaged apps)
539-
std::wstring appName = aumid;
540-
size_t lastDot = appName.find_last_of(L'.');
541-
if (lastDot != std::wstring::npos && lastDot + 1 < appName.length()) {
542-
appName = appName.substr(lastDot + 1);
543-
}
544-
createShortcut(aumid, appName);
545-
546452
hr = g_jumpList->SetAppID(aumid.c_str());
547453
if (FAILED(hr)) {
548454
g_jumpList.Reset();

notification-common/src/main/kotlin/io/github/kdroidfilter/nucleus/notification/common/Notification.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class Notification internal constructor(
2323
}
2424

2525
/** An action button on a notification. */
26+
@ConsistentCopyVisibility
2627
data class NotificationButton internal constructor(
2728
val title: String,
2829
val onClick: () -> Unit,

notification-common/src/main/kotlin/io/github/kdroidfilter/nucleus/notification/common/internal/MacOsDispatcher.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ internal class MacOsDispatcher private constructor() : PlatformDispatcher {
143143
}
144144

145145
return if (sendError != null) {
146-
NotificationResult.Failure(sendError!!)
146+
NotificationResult.Failure(sendError)
147147
} else {
148148
NotificationResult.Success(NotificationHandle(identifier, this))
149149
}

0 commit comments

Comments
 (0)