From 94903dcc33be79d2a10cfe76cffe8220c834941c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 14 Aug 2026 20:40:37 +0600 Subject: [PATCH] feat: integrate Nucleus framework for enhanced window management and webview support --- AGENTS.md | 1 + build.gradle.kts | 1 + composeApp/build.gradle.kts | 91 ++++++++++--------- .../webview/PlatformWebviewConfig.android.kt | 2 +- .../spotube/core/webview/PlatformWebView.kt | 6 +- .../core/webview/PlatformWebviewConfig.kt | 2 +- .../core/webview/PlatformWebviewScreen.kt | 14 +-- .../core/webview/PlatformWebviewConfig.ios.kt | 2 +- .../ui/component/ApplicationMainBar.jvm.kt | 47 +++++----- .../core/ui/component/LocalApplication.kt | 3 +- .../spotube/core/ui/component/LocalWindow.kt | 4 +- .../core/webview/PlatformWebviewConfig.jvm.kt | 2 +- .../kotlin/dev/krtirtho/spotube/main.kt | 15 ++- gradle/libs.versions.toml | 11 ++- 14 files changed, 109 insertions(+), 92 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3e9f8487..ee383842 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ ## Dependencies - `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations. - Kotlin: `2.3.0`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`). +- **compose-webview (desktop) dispatcher hijack:** `dev.nucleusframework:composewebview` transitively pulls `nucleus.decorated-window-tao`, which registers a `TaoMainDispatcherFactory` (`loadPriority = 100`) via `META-INF/services` that hijacks `Dispatchers.Main` away from the Swing EDT. This breaks lifecycle's `enforceMainThreadIfNeeded` (window fails with "Method addObserver must be called on the main thread"). The app overrides it with `SwingMainDispatcherFactory` (`composeApp/src/jvmMain/.../core/coroutines/`, `loadPriority = Int.MAX_VALUE`) which pins `Dispatchers.Main` back to the Swing EDT. Keep that factory + its `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory` resource; the long-term fix is in the webview (make the Tao dispatcher opt-in or lower its priority). ## Codegen and plugin packaging - JS plugin bundles: `:js_plugin_example:packageDevelopmentPlugin` and `:js_plugin_example:packageProductionPlugin`. Output is `.smplug` files in `js_plugin_example/build/distributions/`. diff --git a/build.gradle.kts b/build.gradle.kts index 55e4c403..b4313c19 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,4 +33,5 @@ plugins { alias(libs.plugins.uniffi) apply false alias(libs.plugins.cargo) apply false alias(libs.plugins.mokkery) apply false + alias(libs.plugins.neucleusFramework) apply false } \ No newline at end of file diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b1d64839..d753b416 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -17,7 +17,7 @@ import gobley.gradle.GobleyHost import gobley.gradle.cargo.dsl.jvm -import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import dev.nucleusframework.desktop.application.dsl.TargetFormat import org.jetbrains.compose.reload.gradle.ComposeHotRun import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.io.FileInputStream @@ -35,6 +35,7 @@ plugins { alias(libs.plugins.uniffi) alias(libs.plugins.cargo) alias(libs.plugins.mokkery) + alias(libs.plugins.neucleusFramework) kotlin("plugin.atomicfu") version libs.versions.kotlin } @@ -220,6 +221,10 @@ kotlin { implementation(libs.appdirs) implementation(libs.jna) + + implementation(libs.nucleus.core.runtime) + implementation(libs.nucleus.nucleus.application) + implementation(libs.nucleus.decorated.window.tao) } } } @@ -280,56 +285,56 @@ dependencies { debugImplementation(libs.compose.uiTooling) } -compose.desktop { - application { - mainClass = "dev.krtirtho.spotube.MainKt" +nucleus.application { + mainClass = "dev.krtirtho.spotube.MainKt" - jvmArgs += listOf( - "--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work - // --- ADD THESE FOR SPEED & LOW RAM --- - "-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront) - "-Xmx384m", // Cap the max heap (VLC and WebView need a bit of room) - "-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup! - "-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB + jvmArgs += listOf( + "--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work + // --- ADD THESE FOR SPEED & LOW RAM --- + "-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront) + "-Xmx384m", // Cap the max heap (VLC and WebView need a bit of room) + "-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup! + "-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB + ) + + nativeDistributions { + packageName = "dev.krtirtho.spotube" + packageVersion = project.findProperty("versionName") as String? ?: "6.0.0" + licenseFile = project.file("../LICENSE") + + appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory) + + targetFormats( + TargetFormat.Dmg, + TargetFormat.Deb, + TargetFormat.Rpm, + TargetFormat.AppImage, + TargetFormat.Msi, + TargetFormat.Exe, ) - nativeDistributions { - packageName = "dev.krtirtho.spotube" - packageVersion = project.findProperty("versionName") as String? ?: "6.0.0" - licenseFile = project.file("../LICENSE") + modules("jdk.unsupported") - targetFormats( - TargetFormat.Dmg, - TargetFormat.Deb, - TargetFormat.Rpm, - TargetFormat.AppImage, - TargetFormat.Msi, - TargetFormat.Exe, - ) - - modules("jdk.unsupported") - - linux { - modules("jdk.security.auth") - } - windows { - shortcut = true - menu = true - dirChooser = true - perUserInstall = true - } - - macOS { - notarization { - - } - } + linux { + modules("jdk.security.auth") + } + windows { + shortcut = true + menu = true + dirChooser = true + perUserInstall = true } - buildTypes.release.proguard { - configurationFiles.from(project.file("proguard-rules.pro")) + macOS { + notarization { + + } } } + + buildTypes.release.proguard { + configurationFiles.from(project.file("proguard-rules.pro")) + } } // Issue with compose plugin diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt index 50bef4af..52b71013 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt @@ -20,7 +20,7 @@ package dev.krtirtho.spotube.core.webview import android.webkit.CookieManager import android.webkit.WebStorage import android.webkit.WebView -import io.github.kdroidfilter.webview.web.WebViewState +import dev.nucleusframework.webview.web.WebViewState actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) { val nativeWebView = webView.webView?.nativeWebView as? WebView ?: return diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt index 1dc64f24..b9880a35 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt @@ -17,9 +17,9 @@ package dev.krtirtho.spotube.core.webview -import io.github.kdroidfilter.webview.web.WebContent -import io.github.kdroidfilter.webview.web.WebViewNavigator -import io.github.kdroidfilter.webview.cookie.CookieManager +import dev.nucleusframework.webview.web.WebContent +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.cookie.CookieManager import dev.krtirtho.plugin_interfaces.host_apis.Cookie import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.navigation.NavigationCommands diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt index 39d345f1..c67c8fd0 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt @@ -17,7 +17,7 @@ package dev.krtirtho.spotube.core.webview -import io.github.kdroidfilter.webview.web.WebViewState +import dev.nucleusframework.webview.web.WebViewState expect fun platformWebviewConfig(webView: WebViewState, pluginId: String?) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt index 828a7a36..5d30fbc3 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt @@ -51,13 +51,13 @@ import compose.icons.feathericons.ChevronLeft import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.X import dev.krtirtho.spotube.core.tools.user_agents.UserAgents -import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler -import io.github.kdroidfilter.webview.jsbridge.JsMessage -import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge -import io.github.kdroidfilter.webview.web.WebView -import io.github.kdroidfilter.webview.web.WebViewNavigator -import io.github.kdroidfilter.webview.web.WebViewState -import io.github.kdroidfilter.webview.web.rememberWebViewNavigator +import dev.nucleusframework.webview.jsbridge.IJsMessageHandler +import dev.nucleusframework.webview.jsbridge.JsMessage +import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge +import dev.nucleusframework.webview.web.WebView +import dev.nucleusframework.webview.web.WebViewNavigator +import dev.nucleusframework.webview.web.WebViewState +import dev.nucleusframework.webview.web.rememberWebViewNavigator class PostMessageHandler( private val onMessageReceived: (String) -> Unit = {} diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt index fbb391db..807346fe 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt @@ -17,7 +17,7 @@ package dev.krtirtho.spotube.core.webview -import io.github.kdroidfilter.webview.web.WebViewState +import dev.nucleusframework.webview.web.WebViewState import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSDate import platform.Foundation.NSHTTPCookieStorage diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt index c70627dc..cd346728 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/ApplicationMainBar.jvm.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.window.WindowDraggableArea @@ -44,6 +45,10 @@ import dev.krtirtho.spotube.resources.iconsax.FluentMaximize import dev.krtirtho.spotube.resources.iconsax.FluentMinus import dev.krtirtho.spotube.resources.iconsax.FluentSquareMultiple import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.nucleusframework.window.WindowControls +import dev.nucleusframework.window.WindowControlsRenderer +import dev.nucleusframework.window.WindowDoubleClickAction +import dev.nucleusframework.window.windowDragArea import org.koin.compose.koinInject @@ -55,27 +60,27 @@ actual fun ApplicationMainBar( actions: @Composable (RowScope.() -> Unit), backButton: Boolean, ) { - LocalWindowScope.current.WindowDraggableArea { - Box( - modifier = Modifier.fillMaxWidth() - ) { - TopAppBar( - title = title, - actions = { - actions() - }, - navigationIcon = { - if (backButton) { - ApplicationBackButton() - } - }, - subtitle = subtitle, - modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons - ) - WindowButtons( - modifier = Modifier.align(Alignment.TopEnd) - ) - } + Box( + modifier = Modifier.fillMaxWidth() + .windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize) + ) { + TopAppBar( + title = title, + actions = { + actions() + }, + navigationIcon = { + if (backButton) { + ApplicationBackButton() + } + }, + subtitle = subtitle, + modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons + ) + LocalWindowScope.current.WindowControls( + modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp), + renderer = WindowControlsRenderer.Platform, // default + ) } } diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalApplication.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalApplication.kt index a4cb687e..ab238909 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalApplication.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalApplication.kt @@ -19,7 +19,8 @@ package dev.krtirtho.spotube.core.ui.component import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.window.ApplicationScope +import dev.nucleusframework.application.NucleusApplicationScope -val LocalApplicationScope = staticCompositionLocalOf { +val LocalApplicationScope = staticCompositionLocalOf< NucleusApplicationScope> { error("No ApplicationScope found in LocalApplicationScope") } \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalWindow.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalWindow.kt index 00f0465e..f95f7cf1 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalWindow.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/ui/component/LocalWindow.kt @@ -18,11 +18,11 @@ package dev.krtirtho.spotube.core.ui.component import androidx.compose.runtime.compositionLocalOf -import androidx.compose.ui.window.FrameWindowScope import androidx.compose.ui.window.WindowState +import dev.nucleusframework.application.NucleusDecoratedWindowScope // LocalWindow is a LocalComposition that provides access to the current Window instance in the composition hierarchy. -val LocalWindowScope = compositionLocalOf { +val LocalWindowScope = compositionLocalOf { error("No Window found in LocalWindowScope") } val LocalWindowState = compositionLocalOf { diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt index 2742885f..818f3ab5 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt @@ -18,7 +18,7 @@ package dev.krtirtho.spotube.core.webview import dev.krtirtho.spotube.core.paths.Paths -import io.github.kdroidfilter.webview.web.WebViewState +import dev.nucleusframework.webview.web.WebViewState import okio.FileSystem import okio.Path.Companion.toPath import org.koin.core.context.GlobalContext diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt index e03b79f5..7a9fdec6 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/main.kt @@ -25,9 +25,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.WindowDecoration -import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import dev.krtirtho.spotube.core.di.initKoin import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader @@ -37,6 +34,9 @@ import dev.krtirtho.spotube.core.ui.component.LocalApplicationScope import dev.krtirtho.spotube.core.ui.component.LocalWindowScope import dev.krtirtho.spotube.core.ui.component.LocalWindowState import dev.krtirtho.spotube.modules.settings.SettingsProvider +import dev.nucleusframework.application.DecoratedWindow +import dev.nucleusframework.application.NucleusBackend +import dev.nucleusframework.application.nucleusApplication import io.github.vinceglb.filekit.FileKit import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -61,7 +61,7 @@ fun main() { NewPipeDownloader.init(KoinPathsProvider.paths) val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - application { + nucleusApplication(backend = NucleusBackend.Tao) { val windowState = rememberWindowState( width = 1080.dp, height = 720.dp @@ -84,7 +84,7 @@ fun main() { } ) - Window( + DecoratedWindow( state = windowState, onCloseRequest = { if (minimizeToTray) { @@ -96,12 +96,11 @@ fun main() { } }, title = "Spotube", - decoration = WindowDecoration.Undecorated(), visible = isWindowVisible, ) { CompositionLocalProvider( - LocalApplicationScope provides this@application, - LocalWindowScope provides this@Window, + LocalApplicationScope provides this@nucleusApplication, + LocalWindowScope provides this@DecoratedWindow, LocalWindowState provides windowState ) { App() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 14546439..35dabd8f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,6 +47,7 @@ materialKolor = "4.1.1" murmurhash = "0.4.2" newpipeextractor = "v0.26.2" newpipeExtractorKmp = "1.3.0" +nucleusNucleusApplication = "2.4.4" semver = "2.1.0" vlcj = "4.12.1" vlcjNative = "4.12.0" @@ -56,7 +57,7 @@ kotlinStdlib = "2.3.21" runner = "1.7.0" core = "1.7.0" filekit = "0.14.2" -compose-webview = "0.1.0-SNAPSHOT" +compose-webview = "1.0.1" koin = "4.2.2" multiplatform-nav3-ui = "1.1.1" compose-multiplatform-adaptive = "1.3.0-beta02" @@ -119,6 +120,9 @@ material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "m murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" } newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } +nucleus-core-runtime = { module = "dev.nucleusframework:nucleus.core-runtime", version.ref = "nucleusNucleusApplication" } +nucleus-decorated-window-tao = { module = "dev.nucleusframework:nucleus.decorated-window-tao", version.ref = "nucleusNucleusApplication" } +nucleus-nucleus-application = { module = "dev.nucleusframework:nucleus.nucleus-application", version.ref = "nucleusNucleusApplication" } semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" } vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" } vlcj-natives = { module = "uk.co.caprica:vlcj-natives", version.ref = "vlcjNative" } @@ -140,7 +144,7 @@ androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.ref = "filekit" } filekit-dialogs = { group = "io.github.vinceglb", name = "filekit-dialogs", version.ref = "filekit" } filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "filekit" } -compose-webview = { module = "io.github.kdroidfilter:composewebview", version.ref = "compose-webview" } +compose-webview = { module = "dev.nucleusframework:composewebview", version.ref = "compose-webview" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } @@ -182,4 +186,5 @@ kotlinxAtomicFu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "kotlin uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" } cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" } mokkery = { id = "dev.mokkery", version.ref = "mokkery" } -vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" } \ No newline at end of file +vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" } +neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" } \ No newline at end of file