feat: integrate Nucleus framework for enhanced window management and webview support

This commit is contained in:
Kingkor Roy Tirtho 2026-08-14 20:40:37 +06:00
parent d95e1aad70
commit 94903dcc33
14 changed files with 109 additions and 92 deletions

View File

@ -20,6 +20,7 @@
## Dependencies ## Dependencies
- `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations. - `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`). - 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 ## 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/`. - JS plugin bundles: `:js_plugin_example:packageDevelopmentPlugin` and `:js_plugin_example:packageProductionPlugin`. Output is `.smplug` files in `js_plugin_example/build/distributions/`.

View File

@ -33,4 +33,5 @@ plugins {
alias(libs.plugins.uniffi) apply false alias(libs.plugins.uniffi) apply false
alias(libs.plugins.cargo) apply false alias(libs.plugins.cargo) apply false
alias(libs.plugins.mokkery) apply false alias(libs.plugins.mokkery) apply false
alias(libs.plugins.neucleusFramework) apply false
} }

View File

@ -17,7 +17,7 @@
import gobley.gradle.GobleyHost import gobley.gradle.GobleyHost
import gobley.gradle.cargo.dsl.jvm 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.compose.reload.gradle.ComposeHotRun
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.io.FileInputStream import java.io.FileInputStream
@ -35,6 +35,7 @@ plugins {
alias(libs.plugins.uniffi) alias(libs.plugins.uniffi)
alias(libs.plugins.cargo) alias(libs.plugins.cargo)
alias(libs.plugins.mokkery) alias(libs.plugins.mokkery)
alias(libs.plugins.neucleusFramework)
kotlin("plugin.atomicfu") version libs.versions.kotlin kotlin("plugin.atomicfu") version libs.versions.kotlin
} }
@ -220,6 +221,10 @@ kotlin {
implementation(libs.appdirs) implementation(libs.appdirs)
implementation(libs.jna) 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) debugImplementation(libs.compose.uiTooling)
} }
compose.desktop { nucleus.application {
application { mainClass = "dev.krtirtho.spotube.MainKt"
mainClass = "dev.krtirtho.spotube.MainKt"
jvmArgs += listOf( jvmArgs += listOf(
"--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work "--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work
// --- ADD THESE FOR SPEED & LOW RAM --- // --- ADD THESE FOR SPEED & LOW RAM ---
"-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront) "-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront)
"-Xmx384m", // Cap the max heap (VLC and WebView need a bit of room) "-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:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup!
"-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB "-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 { modules("jdk.unsupported")
packageName = "dev.krtirtho.spotube"
packageVersion = project.findProperty("versionName") as String? ?: "6.0.0"
licenseFile = project.file("../LICENSE")
targetFormats( linux {
TargetFormat.Dmg, modules("jdk.security.auth")
TargetFormat.Deb, }
TargetFormat.Rpm, windows {
TargetFormat.AppImage, shortcut = true
TargetFormat.Msi, menu = true
TargetFormat.Exe, dirChooser = true
) perUserInstall = true
modules("jdk.unsupported")
linux {
modules("jdk.security.auth")
}
windows {
shortcut = true
menu = true
dirChooser = true
perUserInstall = true
}
macOS {
notarization {
}
}
} }
buildTypes.release.proguard { macOS {
configurationFiles.from(project.file("proguard-rules.pro")) notarization {
}
} }
} }
buildTypes.release.proguard {
configurationFiles.from(project.file("proguard-rules.pro"))
}
} }
// Issue with compose plugin // Issue with compose plugin

View File

@ -20,7 +20,7 @@ package dev.krtirtho.spotube.core.webview
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebStorage import android.webkit.WebStorage
import android.webkit.WebView import android.webkit.WebView
import io.github.kdroidfilter.webview.web.WebViewState import dev.nucleusframework.webview.web.WebViewState
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) { actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
val nativeWebView = webView.webView?.nativeWebView as? WebView ?: return val nativeWebView = webView.webView?.nativeWebView as? WebView ?: return

View File

@ -17,9 +17,9 @@
package dev.krtirtho.spotube.core.webview package dev.krtirtho.spotube.core.webview
import io.github.kdroidfilter.webview.web.WebContent import dev.nucleusframework.webview.web.WebContent
import io.github.kdroidfilter.webview.web.WebViewNavigator import dev.nucleusframework.webview.web.WebViewNavigator
import io.github.kdroidfilter.webview.cookie.CookieManager import dev.nucleusframework.webview.cookie.CookieManager
import dev.krtirtho.plugin_interfaces.host_apis.Cookie import dev.krtirtho.plugin_interfaces.host_apis.Cookie
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands

View File

@ -17,7 +17,7 @@
package dev.krtirtho.spotube.core.webview 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?) expect fun platformWebviewConfig(webView: WebViewState, pluginId: String?)

View File

@ -51,13 +51,13 @@ import compose.icons.feathericons.ChevronLeft
import compose.icons.feathericons.ChevronRight import compose.icons.feathericons.ChevronRight
import compose.icons.feathericons.X import compose.icons.feathericons.X
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
import io.github.kdroidfilter.webview.jsbridge.JsMessage import dev.nucleusframework.webview.jsbridge.JsMessage
import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
import io.github.kdroidfilter.webview.web.WebView import dev.nucleusframework.webview.web.WebView
import io.github.kdroidfilter.webview.web.WebViewNavigator import dev.nucleusframework.webview.web.WebViewNavigator
import io.github.kdroidfilter.webview.web.WebViewState import dev.nucleusframework.webview.web.WebViewState
import io.github.kdroidfilter.webview.web.rememberWebViewNavigator import dev.nucleusframework.webview.web.rememberWebViewNavigator
class PostMessageHandler( class PostMessageHandler(
private val onMessageReceived: (String) -> Unit = {} private val onMessageReceived: (String) -> Unit = {}

View File

@ -17,7 +17,7 @@
package dev.krtirtho.spotube.core.webview package dev.krtirtho.spotube.core.webview
import io.github.kdroidfilter.webview.web.WebViewState import dev.nucleusframework.webview.web.WebViewState
import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDate import platform.Foundation.NSDate
import platform.Foundation.NSHTTPCookieStorage import platform.Foundation.NSHTTPCookieStorage

View File

@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.window.WindowDraggableArea 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.FluentMinus
import dev.krtirtho.spotube.resources.iconsax.FluentSquareMultiple import dev.krtirtho.spotube.resources.iconsax.FluentSquareMultiple
import dev.krtirtho.spotube.resources.iconsax.Iconsax 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 import org.koin.compose.koinInject
@ -55,27 +60,27 @@ actual fun ApplicationMainBar(
actions: @Composable (RowScope.() -> Unit), actions: @Composable (RowScope.() -> Unit),
backButton: Boolean, backButton: Boolean,
) { ) {
LocalWindowScope.current.WindowDraggableArea { Box(
Box( modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth() .windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize)
) { ) {
TopAppBar( TopAppBar(
title = title, title = title,
actions = { actions = {
actions() actions()
}, },
navigationIcon = { navigationIcon = {
if (backButton) { if (backButton) {
ApplicationBackButton() ApplicationBackButton()
} }
}, },
subtitle = subtitle, subtitle = subtitle,
modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons
) )
WindowButtons( LocalWindowScope.current.WindowControls(
modifier = Modifier.align(Alignment.TopEnd) modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp),
) renderer = WindowControlsRenderer.Platform, // default
} )
} }
} }

View File

@ -19,7 +19,8 @@ package dev.krtirtho.spotube.core.ui.component
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.window.ApplicationScope import androidx.compose.ui.window.ApplicationScope
import dev.nucleusframework.application.NucleusApplicationScope
val LocalApplicationScope = staticCompositionLocalOf<ApplicationScope> { val LocalApplicationScope = staticCompositionLocalOf< NucleusApplicationScope> {
error("No ApplicationScope found in LocalApplicationScope") error("No ApplicationScope found in LocalApplicationScope")
} }

View File

@ -18,11 +18,11 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.window.FrameWindowScope
import androidx.compose.ui.window.WindowState 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. // LocalWindow is a LocalComposition that provides access to the current Window instance in the composition hierarchy.
val LocalWindowScope = compositionLocalOf<FrameWindowScope> { val LocalWindowScope = compositionLocalOf<NucleusDecoratedWindowScope> {
error("No Window found in LocalWindowScope") error("No Window found in LocalWindowScope")
} }
val LocalWindowState = compositionLocalOf<WindowState> { val LocalWindowState = compositionLocalOf<WindowState> {

View File

@ -18,7 +18,7 @@
package dev.krtirtho.spotube.core.webview package dev.krtirtho.spotube.core.webview
import dev.krtirtho.spotube.core.paths.Paths 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.FileSystem
import okio.Path.Companion.toPath import okio.Path.Companion.toPath
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext

View File

@ -25,9 +25,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.unit.dp 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 androidx.compose.ui.window.rememberWindowState
import dev.krtirtho.spotube.core.di.initKoin import dev.krtirtho.spotube.core.di.initKoin
import dev.krtirtho.spotube.core.newpipe.NewPipeDownloader 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.LocalWindowScope
import dev.krtirtho.spotube.core.ui.component.LocalWindowState import dev.krtirtho.spotube.core.ui.component.LocalWindowState
import dev.krtirtho.spotube.modules.settings.SettingsProvider 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 io.github.vinceglb.filekit.FileKit
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -61,7 +61,7 @@ fun main() {
NewPipeDownloader.init(KoinPathsProvider.paths) NewPipeDownloader.init(KoinPathsProvider.paths)
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
application { nucleusApplication(backend = NucleusBackend.Tao) {
val windowState = rememberWindowState( val windowState = rememberWindowState(
width = 1080.dp, width = 1080.dp,
height = 720.dp height = 720.dp
@ -84,7 +84,7 @@ fun main() {
} }
) )
Window( DecoratedWindow(
state = windowState, state = windowState,
onCloseRequest = { onCloseRequest = {
if (minimizeToTray) { if (minimizeToTray) {
@ -96,12 +96,11 @@ fun main() {
} }
}, },
title = "Spotube", title = "Spotube",
decoration = WindowDecoration.Undecorated(),
visible = isWindowVisible, visible = isWindowVisible,
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalApplicationScope provides this@application, LocalApplicationScope provides this@nucleusApplication,
LocalWindowScope provides this@Window, LocalWindowScope provides this@DecoratedWindow,
LocalWindowState provides windowState LocalWindowState provides windowState
) { ) {
App() App()

View File

@ -47,6 +47,7 @@ materialKolor = "4.1.1"
murmurhash = "0.4.2" murmurhash = "0.4.2"
newpipeextractor = "v0.26.2" newpipeextractor = "v0.26.2"
newpipeExtractorKmp = "1.3.0" newpipeExtractorKmp = "1.3.0"
nucleusNucleusApplication = "2.4.4"
semver = "2.1.0" semver = "2.1.0"
vlcj = "4.12.1" vlcj = "4.12.1"
vlcjNative = "4.12.0" vlcjNative = "4.12.0"
@ -56,7 +57,7 @@ kotlinStdlib = "2.3.21"
runner = "1.7.0" runner = "1.7.0"
core = "1.7.0" core = "1.7.0"
filekit = "0.14.2" filekit = "0.14.2"
compose-webview = "0.1.0-SNAPSHOT" compose-webview = "1.0.1"
koin = "4.2.2" koin = "4.2.2"
multiplatform-nav3-ui = "1.1.1" multiplatform-nav3-ui = "1.1.1"
compose-multiplatform-adaptive = "1.3.0-beta02" 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" } murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }
newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" } newpipe-extractor-kmp = { module = "io.github.yushosei:newpipe-extractor-kmp", version.ref = "newpipeExtractorKmp" }
newpipeextractor = { module = "com.github.teamnewpipe:NewPipeExtractor", version.ref = "newpipeextractor" } 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" } semver = { module = "net.swiftzer.semver:semver", version.ref = "semver" }
vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" } vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" }
vlcj-natives = { module = "uk.co.caprica:vlcj-natives", version.ref = "vlcjNative" } 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-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 = { group = "io.github.vinceglb", name = "filekit-dialogs", version.ref = "filekit" }
filekit-dialogs-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", 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-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", 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" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
@ -183,3 +187,4 @@ uniffi = { id = "dev.gobley.uniffi", version.ref = "uniffi" }
cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" } cargo = { id = "dev.gobley.cargo", version.ref = "uniffi" }
mokkery = { id = "dev.mokkery", version.ref = "mokkery" } mokkery = { id = "dev.mokkery", version.ref = "mokkery" }
vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" } vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish" }
neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" }