diff --git a/.github/workflows/build-natives.yaml b/.github/workflows/build-natives.yaml
index ed8ee7bf..a3a696d1 100644
--- a/.github/workflows/build-natives.yaml
+++ b/.github/workflows/build-natives.yaml
@@ -165,3 +165,21 @@ jobs:
name: linux-natives-${{ matrix.arch }}
path: darkmode-detector/src/main/resources/nucleus/native/linux-*/
retention-days: 1
+
+ - name: Build nucleus-notification Linux native shared library
+ run: bash nucleus-notification/src/main/native/linux/build.sh
+ env:
+ CI: true
+
+ - name: Verify nucleus-notification Linux native
+ run: |
+ f="nucleus-notification/src/main/resources/nucleus/native/linux-${{ matrix.arch }}/libnucleus_notification.so"
+ if [ ! -f "$f" ]; then echo "MISSING: $f" >&2; exit 1; fi
+ echo "OK: $f ($(wc -c < "$f") bytes)"
+
+ - name: Upload nucleus-notification Linux shared library
+ uses: actions/upload-artifact@v4
+ with:
+ name: linux-natives-notification-${{ matrix.arch }}
+ path: nucleus-notification/src/main/resources/nucleus/native/linux-*/
+ retention-days: 1
diff --git a/.github/workflows/publish-maven.yaml b/.github/workflows/publish-maven.yaml
index 4a0c0a44..3a6b7090 100644
--- a/.github/workflows/publish-maven.yaml
+++ b/.github/workflows/publish-maven.yaml
@@ -38,6 +38,20 @@ jobs:
pattern: 'decorated-window-*'
merge-multiple: true
+ - name: Download nucleus-notification x64 artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: nucleus-notification/src/main/resources/nucleus/native/
+ pattern: 'linux-natives-notification-x64'
+ merge-multiple: true
+
+ - name: Download nucleus-notification ARM64 artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: nucleus-notification/src/main/resources/nucleus/native/
+ pattern: 'linux-natives-notification-aarch64'
+ merge-multiple: true
+
- name: Verify all natives present
run: |
EXPECTED=(
@@ -53,6 +67,8 @@ jobs:
"native-ssl/src/main/resources/nucleus/native/win32-aarch64/nucleus_ssl.dll"
"decorated-window/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_macos.dylib"
"decorated-window/src/main/resources/nucleus/native/darwin-x64/libnucleus_macos.dylib"
+ "nucleus-notification/src/main/resources/nucleus/native/linux-x64/libnucleus_notification.so"
+ "nucleus-notification/src/main/resources/nucleus/native/linux-aarch64/libnucleus_notification.so"
)
MISSING=0
for f in "${EXPECTED[@]}"; do
diff --git a/.gitignore b/.gitignore
index 76c41d07..2a6ce5ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,6 +17,7 @@ site
decorated-window/src/main/resources/nucleus/native/
darkmode-detector/src/main/resources/nucleus/native/
native-ssl/src/main/resources/nucleus/native/
+nucleus-notification/src/main/resources/nucleus/native/
# MSVC build artifacts
*.obj
diff --git a/core-runtime/src/main/kotlin/io/github/kdroidfilter/nucleus/core/runtime/tools/Logger.kt b/core-runtime/src/main/kotlin/io/github/kdroidfilter/nucleus/core/runtime/tools/Logger.kt
index ec406202..de33784c 100644
--- a/core-runtime/src/main/kotlin/io/github/kdroidfilter/nucleus/core/runtime/tools/Logger.kt
+++ b/core-runtime/src/main/kotlin/io/github/kdroidfilter/nucleus/core/runtime/tools/Logger.kt
@@ -49,31 +49,31 @@ private val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS
private fun getCurrentTimestamp(): String = LocalDateTime.now().format(timeFormatter)
-internal fun debugln(message: () -> String) {
+public fun debugln(message: () -> String) {
if (allowNucleusRuntimeLogging && composeNativeTrayLoggingLevel <= DEBUG) {
println("[${getCurrentTimestamp()}] ${message()}")
}
}
-internal fun verboseln(message: () -> String) {
+public fun verboseln(message: () -> String) {
if (allowNucleusRuntimeLogging && composeNativeTrayLoggingLevel <= VERBOSE) {
println("[${getCurrentTimestamp()}] ${message()}", COLOR_LIGHT_GRAY)
}
}
-internal fun infoln(message: () -> String) {
+public fun infoln(message: () -> String) {
if (allowNucleusRuntimeLogging && composeNativeTrayLoggingLevel <= INFO) {
println("[${getCurrentTimestamp()}] ${message()}", COLOR_AQUA)
}
}
-internal fun warnln(message: () -> String) {
+public fun warnln(message: () -> String) {
if (allowNucleusRuntimeLogging && composeNativeTrayLoggingLevel <= WARN) {
println("[${getCurrentTimestamp()}] ${message()}", COLOR_ORANGE)
}
}
-internal fun errorln(message: () -> String) {
+public fun errorln(message: () -> String) {
if (allowNucleusRuntimeLogging && composeNativeTrayLoggingLevel <= ERROR) {
println("[${getCurrentTimestamp()}] ${message()}", COLOR_RED)
}
diff --git a/example/build.gradle.kts b/example/build.gradle.kts
index 8976775d..bcf1cf69 100644
--- a/example/build.gradle.kts
+++ b/example/build.gradle.kts
@@ -24,6 +24,7 @@ dependencies {
implementation(project(":updater-runtime"))
implementation(project(":darkmode-detector"))
implementation(project(":decorated-window-material"))
+ implementation(project(":nucleus-notification"))
}
val releaseVersion =
@@ -65,7 +66,7 @@ nucleus.application {
// --- Native libs handling ---
cleanupNativeLibs = true // Auto cleanup native libraries
- enableAotCache = true // Enable AOT compilation cache
+ enableAotCache = false // Enable AOT compilation cache
splashImage = "splash.png" // Splash screen image file
homepage = "https://github.com/KdroidFilter/NucleusDemo"
diff --git a/example/config/ktlint/baseline.xml b/example/config/ktlint/baseline.xml
new file mode 100644
index 00000000..3a7c5950
--- /dev/null
+++ b/example/config/ktlint/baseline.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/example/detekt-baseline.xml b/example/detekt-baseline.xml
new file mode 100644
index 00000000..334893d8
--- /dev/null
+++ b/example/detekt-baseline.xml
@@ -0,0 +1,7 @@
+
+
+
+
+ FunctionNaming:Main.kt$@OptIn(ExperimentalNotificationsApi::class) @Composable private fun NotificationButton()
+
+
diff --git a/example/src/main/kotlin/com/example/demo/Main.kt b/example/src/main/kotlin/com/example/demo/Main.kt
index 6fad613c..a70854b2 100644
--- a/example/src/main/kotlin/com/example/demo/Main.kt
+++ b/example/src/main/kotlin/com/example/demo/Main.kt
@@ -56,6 +56,9 @@ import io.github.kdroidfilter.nucleus.core.runtime.DeepLinkHandler
import io.github.kdroidfilter.nucleus.core.runtime.Platform
import io.github.kdroidfilter.nucleus.core.runtime.SingleInstanceManager
import io.github.kdroidfilter.nucleus.darkmodedetector.isSystemInDarkMode
+import io.github.kdroidfilter.nucleus.notification.builder.ExperimentalNotificationsApi
+import io.github.kdroidfilter.nucleus.notification.builder.notification
+import io.github.kdroidfilter.nucleus.notification.builder.sendNotification
import io.github.kdroidfilter.nucleus.updater.NucleusUpdater
import io.github.kdroidfilter.nucleus.updater.UpdateResult
import io.github.kdroidfilter.nucleus.updater.provider.GitHubProvider
@@ -253,6 +256,10 @@ fun app() {
) {
NucleusAtom(atomSize = 200.dp)
+ Spacer(modifier = Modifier.height(24.dp))
+
+ NotificationButton()
+
if (currentDeepLink != null) {
Spacer(modifier = Modifier.height(16.dp))
Text(
@@ -300,6 +307,31 @@ fun app() {
}
}
+@OptIn(ExperimentalNotificationsApi::class)
+@Composable
+private fun NotificationButton() {
+ var notificationSent by remember { mutableStateOf(false) }
+
+ Button(
+ onClick = {
+ notificationSent = true
+ notification(
+ title = "Nucleus Demo",
+ smallIcon = "dialog-information",
+ message = "This is a test notification from Nucleus!",
+ onActivated = { println("Notification activated!") },
+ onDismissed = { reason -> println("Notification dismissed: $reason") },
+ onFailed = { println("Notification failed!") }
+ ) {
+ button("Reply") { println("Reply button clicked!") }
+ button("Dismiss") { println("Dismiss button clicked!") }
+ }.send()
+ }
+ ) {
+ Text(if (notificationSent) "Notification Sent!" else "Send Notification")
+ }
+}
+
private enum class ThemeMode {
System,
Dark,
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 465968d6..ca21de32 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -14,6 +14,7 @@ jbrApi = "1.10.1"
jna = "5.17.0"
okhttp = "4.12.0"
ktor = "3.4.0"
+kotlinxCoroutines = "1.10.2"
[plugins]
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt"}
@@ -41,3 +42,4 @@ ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-apache5 = { module = "io.ktor:ktor-client-apache5", version.ref = "ktor" }
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
diff --git a/nucleus-notification/build.gradle.kts b/nucleus-notification/build.gradle.kts
new file mode 100644
index 00000000..8bc2450d
--- /dev/null
+++ b/nucleus-notification/build.gradle.kts
@@ -0,0 +1,145 @@
+import org.apache.tools.ant.taskdefs.condition.Os
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ kotlin("jvm")
+ alias(libs.plugins.vanniktechMavenPublish)
+}
+
+val publishVersion =
+ providers
+ .environmentVariable("GITHUB_REF")
+ .orNull
+ ?.removePrefix("refs/tags/v")
+ ?: "1.0.0"
+
+dependencies {
+ compileOnly(project(":core-runtime"))
+ implementation(libs.kotlinx.coroutines.core)
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_11)
+ }
+}
+
+val nativeResourceDir = layout.projectDirectory.dir("src/main/resources/nucleus/native")
+
+val buildNativeMacOs by tasks.registering(Exec::class) {
+ description = "Compiles the Objective-C JNI bridge into macOS dylibs (arm64 + x64)"
+ group = "build"
+ val hasPrebuilt =
+ nativeResourceDir
+ .dir("darwin-aarch64")
+ .file("libnucleus_notification.dylib")
+ .asFile
+ .exists()
+ enabled = Os.isFamily(Os.FAMILY_MAC) && !hasPrebuilt
+
+ val nativeDir = layout.projectDirectory.dir("src/main/native/macos")
+ inputs.dir(nativeDir)
+ outputs.dir(nativeResourceDir)
+ workingDir(nativeDir)
+ commandLine("bash", "build.sh")
+}
+
+val buildNativeLinux by tasks.registering(Exec::class) {
+ description = "Compiles the C JNI bridge into Linux shared library (x64 + aarch64)"
+ group = "build"
+ val hasPrebuiltX64 = nativeResourceDir
+ .dir("linux-x64")
+ .file("libnucleus_notification.so")
+ .asFile
+ .exists()
+ // Build if x64 is missing (local dev) or if CI is detected (for ARM64)
+ val isCI = System.getenv("CI") != null
+ enabled = (Os.isFamily(Os.FAMILY_UNIX) && !Os.isFamily(Os.FAMILY_MAC)) && (!hasPrebuiltX64 || isCI)
+
+ val nativeDir = layout.projectDirectory.dir("src/main/native/linux")
+ inputs.dir(nativeDir)
+ outputs.dir(nativeResourceDir)
+ workingDir(nativeDir)
+ commandLine("bash", "build.sh")
+}
+
+// Task to ensure ARM64 is built before publishing (CI only)
+val buildNativeLinuxArm64ForPublish by tasks.registering(Exec::class) {
+ description = "Cross-compiles ARM64 Linux native library for publishing"
+ group = "publish"
+ val isCI = System.getenv("CI") != null
+ val hasArm64 = nativeResourceDir
+ .dir("linux-aarch64")
+ .file("libnucleus_notification.so")
+ .asFile
+ .exists()
+
+ // Only run in CI when publishing and ARM64 not already built
+ enabled = isCI && !hasArm64
+
+ val nativeDir = layout.projectDirectory.dir("src/main/native/linux")
+ inputs.dir(nativeDir)
+ outputs.dir(nativeResourceDir.dir("linux-aarch64"))
+ workingDir(nativeDir)
+
+ // Set CI flag to force ARM64 build
+ environment("CI", "true")
+ commandLine("bash", "build.sh")
+}
+
+tasks.processResources {
+ dependsOn(buildNativeMacOs)
+ dependsOn(buildNativeLinux)
+}
+
+tasks.configureEach {
+ if (name == "sourcesJar") {
+ dependsOn(buildNativeMacOs)
+ dependsOn(buildNativeLinux)
+ }
+ // Ensure ARM64 is built before publishing
+ if (name.startsWith("publish") || name.startsWith("publishTo")) {
+ dependsOn(buildNativeLinuxArm64ForPublish)
+ }
+}
+
+mavenPublishing {
+ coordinates("io.github.kdroidfilter", "nucleus.notification", publishVersion)
+
+ pom {
+ name.set("Nucleus Notification")
+ description.set("Desktop notifications for Compose Desktop across Windows, macOS and Linux")
+ url.set("https://github.com/kdroidFilter/Nucleus")
+
+ licenses {
+ license {
+ name.set("MIT License")
+ url.set("https://opensource.org/licenses/MIT")
+ }
+ }
+
+ developers {
+ developer {
+ id.set("kdroidfilter")
+ name.set("kdroidFilter")
+ url.set("https://github.com/kdroidFilter")
+ }
+ }
+
+ scm {
+ url.set("https://github.com/kdroidFilter/Nucleus")
+ connection.set("scm:git:git://github.com/kdroidFilter/Nucleus.git")
+ developerConnection.set("scm:git:ssh://git@github.com/kdroidFilter/Nucleus.git")
+ }
+ }
+
+ publishToMavenCentral()
+ if (project.hasProperty("signingInMemoryKey")) {
+ signAllPublications()
+ }
+}
diff --git a/nucleus-notification/detekt-baseline.xml b/nucleus-notification/detekt-baseline.xml
new file mode 100644
index 00000000..163e51a0
--- /dev/null
+++ b/nucleus-notification/detekt-baseline.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ EmptyFunctionBlock:NoOpNotificationProvider.kt$NoOpNotificationProvider${ }
+ LongParameterList:NotificationBuilder.kt$( title: String = "", message: String = "", largeIcon: String? = null, smallIcon: String? = null, onActivated: (() -> Unit)? = null, onDismissed: ((DismissalReason) -> Unit)? = null, onFailed: (() -> Unit)? = null, builderAction: NotificationBuilder.() -> Unit = {} )
+ LongParameterList:NotificationBuilder.kt$( title: String = "", message: String = "", largeImage: String? = null, smallIcon: String? = null, onActivated: (() -> Unit)? = null, onDismissed: ((DismissalReason) -> Unit)? = null, onFailed: (() -> Unit)? = null, builderAction: NotificationBuilder.() -> Unit = {} )
+ LongParameterList:NotificationBuilder.kt$NotificationBuilder$( var title: String = "", var message: String = "", var largeImagePath: String?, var smallIconPath: String? = null, var onActivated: (() -> Unit)? = null, var onDismissed: ((DismissalReason) -> Unit)? = null, var onFailed: (() -> Unit)? = null, )
+ MagicNumber:MacNotificationProvider.kt$MacNotificationProvider$100
+ MaxLineLength:LinuxNotificationProvider.kt$LinuxNotificationProvider.<no name provided>$infoln { "LinuxNotificationProvider: Closed by user interaction - NOT calling onDismissed" }
+ MaxLineLength:LinuxNotificationProvider.kt$LinuxNotificationProvider.<no name provided>$warnln { "LinuxNotificationProvider: Could not find builder ID for notification ptr: $notificationPtr" }
+ SwallowedException:LinuxNotificationProvider.kt$LinuxNotificationProvider$e: Exception
+ SwallowedException:MacNotificationProvider.kt$MacNotificationProvider$e: Exception
+ TooGenericExceptionCaught:LinuxNotificationProvider.kt$LinuxNotificationProvider$e: Exception
+ TooGenericExceptionCaught:MacNotificationProvider.kt$MacNotificationProvider$e: Exception
+ TooGenericExceptionCaught:NativeNotificationBridge.kt$NativeNotificationBridge$e: Exception
+ TooManyFunctions:NativeNotificationBridge.kt$NativeNotificationBridge
+
+
diff --git a/nucleus-notification/src/main/kotlin/io/github/kdroidfilter/nucleus/notification/builder/NotificationBuilder.kt b/nucleus-notification/src/main/kotlin/io/github/kdroidfilter/nucleus/notification/builder/NotificationBuilder.kt
new file mode 100644
index 00000000..999567f3
--- /dev/null
+++ b/nucleus-notification/src/main/kotlin/io/github/kdroidfilter/nucleus/notification/builder/NotificationBuilder.kt
@@ -0,0 +1,94 @@
+package io.github.kdroidfilter.nucleus.notification.builder
+
+import io.github.kdroidfilter.nucleus.notification.model.Button
+import io.github.kdroidfilter.nucleus.notification.model.DismissalReason
+import io.github.kdroidfilter.nucleus.notification.mac.MacNotificationProvider
+import io.github.kdroidfilter.nucleus.notification.linux.LinuxNotificationProvider
+import io.github.kdroidfilter.nucleus.notification.noop.NoOpNotificationProvider
+import java.util.Locale
+
+@Suppress("ExperimentalAnnotationRetention")
+@RequiresOptIn(
+ level = RequiresOptIn.Level.WARNING,
+ message = "This notifications API is experimental and may change in the future."
+)
+@Retention(AnnotationRetention.BINARY)
+annotation class ExperimentalNotificationsApi
+
+@ExperimentalNotificationsApi
+fun notification(
+ title: String = "",
+ message: String = "",
+ largeIcon: String? = null,
+ smallIcon: String? = null,
+ onActivated: (() -> Unit)? = null,
+ onDismissed: ((DismissalReason) -> Unit)? = null,
+ onFailed: (() -> Unit)? = null,
+ builderAction: NotificationBuilder.() -> Unit = {}
+): Notification {
+ val builder = NotificationBuilder(title, message, largeIcon, smallIcon, onActivated, onDismissed, onFailed)
+ builder.builderAction()
+ return Notification(builder)
+}
+
+@ExperimentalNotificationsApi
+suspend fun sendNotification(
+ title: String = "",
+ message: String = "",
+ largeImage: String? = null,
+ smallIcon: String? = null,
+ onActivated: (() -> Unit)? = null,
+ onDismissed: ((DismissalReason) -> Unit)? = null,
+ onFailed: (() -> Unit)? = null,
+ builderAction: NotificationBuilder.() -> Unit = {}
+): Notification {
+ val notif = notification(title, message, largeImage, smallIcon, onActivated, onDismissed, onFailed, builderAction)
+ notif.send()
+ return notif
+}
+
+class Notification internal constructor(private val builder: NotificationBuilder) {
+ fun send() {
+ val provider = getNotificationProvider()
+ provider.sendNotification(builder)
+ }
+
+ fun hide() {
+ val provider = getNotificationProvider()
+ provider.hideNotification(builder)
+ }
+}
+
+class NotificationBuilder(
+ var title: String = "",
+ var message: String = "",
+ var largeImagePath: String?,
+ var smallIconPath: String? = null,
+ var onActivated: (() -> Unit)? = null,
+ var onDismissed: ((DismissalReason) -> Unit)? = null,
+ var onFailed: (() -> Unit)? = null,
+) {
+ internal val buttons = mutableListOf