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