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
- `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/`.

View File

@ -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
}

View File

@ -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,8 +285,7 @@ dependencies {
debugImplementation(libs.compose.uiTooling)
}
compose.desktop {
application {
nucleus.application {
mainClass = "dev.krtirtho.spotube.MainKt"
jvmArgs += listOf(
@ -298,6 +302,8 @@ compose.desktop {
packageVersion = project.findProperty("versionName") as String? ?: "6.0.0"
licenseFile = project.file("../LICENSE")
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory)
targetFormats(
TargetFormat.Dmg,
TargetFormat.Deb,
@ -329,7 +335,6 @@ compose.desktop {
buildTypes.release.proguard {
configurationFiles.from(project.file("proguard-rules.pro"))
}
}
}
// Issue with compose plugin

View File

@ -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

View File

@ -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

View File

@ -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?)

View File

@ -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 = {}

View File

@ -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

View File

@ -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,9 +60,9 @@ actual fun ApplicationMainBar(
actions: @Composable (RowScope.() -> Unit),
backButton: Boolean,
) {
LocalWindowScope.current.WindowDraggableArea {
Box(
modifier = Modifier.fillMaxWidth()
.windowDragArea(doubleClickAction = WindowDoubleClickAction.ToggleMaximize)
) {
TopAppBar(
title = title,
@ -72,11 +77,11 @@ actual fun ApplicationMainBar(
subtitle = subtitle,
modifier = Modifier.padding(end = 125.dp) // To avoid overlap with window buttons
)
WindowButtons(
modifier = Modifier.align(Alignment.TopEnd)
LocalWindowScope.current.WindowControls(
modifier = Modifier.align(Alignment.TopEnd).heightIn(max = 48.dp),
renderer = WindowControlsRenderer.Platform, // default
)
}
}
}
@Composable

View File

@ -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<ApplicationScope> {
val LocalApplicationScope = staticCompositionLocalOf< NucleusApplicationScope> {
error("No ApplicationScope found in LocalApplicationScope")
}

View File

@ -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<FrameWindowScope> {
val LocalWindowScope = compositionLocalOf<NucleusDecoratedWindowScope> {
error("No Window found in LocalWindowScope")
}
val LocalWindowState = compositionLocalOf<WindowState> {

View File

@ -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

View File

@ -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()

View File

@ -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" }
@ -183,3 +187,4 @@ 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" }
neucleusFramework = { id = "dev.nucleusframework", version.ref = "nucleusNucleusApplication" }