Compare commits

...

2 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
fe0ebb47fe Refactor webview configuration and data clearing functions for iOS and JVM platforms
- Updated `platformWebviewConfig` function to accept an optional `pluginId` parameter for both iOS and JVM implementations.
- Enhanced data directory handling in JVM to create a separate directory for each plugin.
- Implemented `platformClearWebviewData` function for both platforms to clear webview data based on the provided `pluginId`.
- Added necessary imports for iOS and JVM specific functionalities.
2026-07-30 22:53:15 +06:00
Kingkor Roy Tirtho
14ee4f6a4c chore: remove dependabot configuration file 2026-07-30 22:51:07 +06:00
10 changed files with 2215 additions and 2189 deletions

View File

@ -1,16 +0,0 @@
version: 2
enable-beta-ecosystems: true
updates:
- package-ecosystem: "pub"
directory: "/"
schedule:
interval: "daily"
target-branch: "dev"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
target-branch: "dev"

View File

@ -1,24 +1,36 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.webview
import io.github.kdroidfilter.webview.web.WebViewState
actual fun platformWebviewConfig(webView: WebViewState) {
webView.webView?.nativeWebView?.settings?.domStorageEnabled = true
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
val nativeWebView = webView.webView?.nativeWebView as? WebView ?: return
nativeWebView.settings.domStorageEnabled = true
}
actual suspend fun platformClearWebviewData(pluginId: String?) {
if (pluginId == null) return
CookieManager.getInstance().removeAllCookies(null)
CookieManager.getInstance().flush()
WebStorage.getInstance().deleteAllData()
}

View File

@ -1,157 +1,164 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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.krtirtho.plugin_interfaces.host_apis.Cookie
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.component.KoinComponent
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
class WebViewController(val navigationCommands: NavigationCommands): KoinComponent {
private val logger by injectLogger<WebViewController>()
private var cookieManager: CookieManager? = null
private val urlFlow = MutableStateFlow("")
private val webViewCreated = MutableSharedFlow<Unit>(replay = 1)
suspend fun getCookies(url: String): List<Cookie> {
if (cookieManager == null) {
logger.w { "CookieManager is not initialized. Returning empty cookie list." }
return emptyList()
}
val cookies = cookieManager!!.getCookies(url)
val cookieList = mutableListOf<Cookie>()
cookies.forEach {
cookieList.add(
Cookie(
name = it.name,
value = it.value,
domain = it.domain ?: "",
path = it.path,
expiresAt = it.expiresDate,
secure = it.isSecure ?: false,
httpOnly = it.isHttpOnly ?: false
)
)
}
return cookieList
}
private var content: String? = null
private var isHtmlContent: Boolean = false
fun getContent(): String? = content
fun getWebContent(additionalHttpHeaders: Map<String, String> = emptyMap()): WebContent {
if (content == null) throw IllegalStateException("Content is null. This should not happen as WebView should only be opened when content is set.")
if (isHtmlContent) return WebContent.Data(data = content!!, mimeType = "text/html")
return WebContent.Url(url = content!!, additionalHttpHeaders = additionalHttpHeaders)
}
var webViewNavigator: WebViewNavigator? = null
fun emitUrlChange(url: String) {
urlFlow.value = url
}
fun emitWebViewCreated() {
webViewCreated.tryEmit(Unit)
}
fun setCookieManager(cookieManager: CookieManager) {
this.cookieManager = cookieManager
}
@OptIn(ExperimentalCoroutinesApi::class)
fun closeWebview() {
cookieManager = null
content = null
isHtmlContent = false
webViewNavigator = null
navigationCommands.pop(Routes.WebView)
urlFlow.value = ""
webViewCreated.resetReplayCache()
_postMessagesFlow.resetReplayCache()
}
fun dispose() {
cookieManager = null
content = null
isHtmlContent = false
webViewNavigator = null
}
fun navigateTo(url: String) {
if (this.content != null) {
throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
}
this.content = url
this.isHtmlContent = false
navigationCommands.navigateTo(Routes.WebView)
}
fun navigateToHTML(html: String) {
if (this.content != null) {
throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
}
this.content = html
this.isHtmlContent = true
navigationCommands.navigateTo(Routes.WebView)
}
suspend fun evaluateJavascript(jsCode: String): String? {
if (webViewNavigator == null) {
throw IllegalStateException("WebView is not initialized. Cannot evaluate JavaScript.")
}
val completer = CompletableDeferred<String?>()
try {
webViewNavigator?.evaluateJavaScript(jsCode) { result ->
completer.complete(result)
}
} catch (e: Exception) {
completer.completeExceptionally(e)
throw e
}
return completer.await()
}
suspend fun clearData() {
cookieManager?.removeAllCookies()
cookieManager = null
content = null
isHtmlContent = false
webViewNavigator = null
}
val urlChangedFlow = urlFlow.asStateFlow()
val webviewCreatedFlow = webViewCreated.asSharedFlow()
private val _postMessagesFlow = MutableSharedFlow<String>(replay = 1)
fun emitPostMessage(message: String) {
_postMessagesFlow.tryEmit(message)
}
val postMessagesFlow = _postMessagesFlow.asSharedFlow()
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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.krtirtho.plugin_interfaces.host_apis.Cookie
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.component.KoinComponent
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
class WebViewController(val navigationCommands: NavigationCommands): KoinComponent {
private val logger by injectLogger<WebViewController>()
private var cookieManager: CookieManager? = null
private val urlFlow = MutableStateFlow("")
private val webViewCreated = MutableSharedFlow<Unit>(replay = 1)
var currentPluginId: String? = null
private set
suspend fun getCookies(url: String): List<Cookie> {
if (cookieManager == null) {
logger.w { "CookieManager is not initialized. Returning empty cookie list." }
return emptyList()
}
val cookies = cookieManager!!.getCookies(url)
val cookieList = mutableListOf<Cookie>()
cookies.forEach {
cookieList.add(
Cookie(
name = it.name,
value = it.value,
domain = it.domain ?: "",
path = it.path,
expiresAt = it.expiresDate,
secure = it.isSecure ?: false,
httpOnly = it.isHttpOnly ?: false
)
)
}
return cookieList
}
private var content: String? = null
private var isHtmlContent: Boolean = false
fun getContent(): String? = content
fun getWebContent(additionalHttpHeaders: Map<String, String> = emptyMap()): WebContent {
if (content == null) throw IllegalStateException("Content is null. This should not happen as WebView should only be opened when content is set.")
if (isHtmlContent) return WebContent.Data(data = content!!, mimeType = "text/html")
return WebContent.Url(url = content!!, additionalHttpHeaders = additionalHttpHeaders)
}
var webViewNavigator: WebViewNavigator? = null
fun emitUrlChange(url: String) {
urlFlow.value = url
}
fun emitWebViewCreated() {
webViewCreated.tryEmit(Unit)
}
fun setCookieManager(cookieManager: CookieManager) {
this.cookieManager = cookieManager
}
@OptIn(ExperimentalCoroutinesApi::class)
fun closeWebview() {
cookieManager = null
content = null
isHtmlContent = false
webViewNavigator = null
navigationCommands.pop(Routes.WebView)
urlFlow.value = ""
webViewCreated.resetReplayCache()
_postMessagesFlow.resetReplayCache()
}
fun dispose() {
cookieManager = null
content = null
isHtmlContent = false
webViewNavigator = null
}
fun navigateTo(url: String, pluginId: String) {
if (this.content != null) {
throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
}
this.currentPluginId = pluginId
this.content = url
this.isHtmlContent = false
navigationCommands.navigateTo(Routes.WebView)
}
fun navigateToHTML(html: String, pluginId: String) {
if (this.content != null) {
throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
}
this.currentPluginId = pluginId
this.content = html
this.isHtmlContent = true
navigationCommands.navigateTo(Routes.WebView)
}
suspend fun evaluateJavascript(jsCode: String): String? {
if (webViewNavigator == null) {
throw IllegalStateException("WebView is not initialized. Cannot evaluate JavaScript.")
}
val completer = CompletableDeferred<String?>()
try {
webViewNavigator?.evaluateJavaScript(jsCode) { result ->
completer.complete(result)
}
} catch (e: Exception) {
completer.completeExceptionally(e)
throw e
}
return completer.await()
}
suspend fun clearData(pluginId: String? = null) {
cookieManager?.removeAllCookies()
val targetPluginId = pluginId ?: currentPluginId
platformClearWebviewData(targetPluginId)
cookieManager = null
content = null
isHtmlContent = false
webViewNavigator = null
currentPluginId = null
}
val urlChangedFlow = urlFlow.asStateFlow()
val webviewCreatedFlow = webViewCreated.asSharedFlow()
private val _postMessagesFlow = MutableSharedFlow<String>(replay = 1)
fun emitPostMessage(message: String) {
_postMessagesFlow.tryEmit(message)
}
val postMessagesFlow = _postMessagesFlow.asSharedFlow()
}

View File

@ -1,22 +1,24 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.webview
import io.github.kdroidfilter.webview.web.WebViewState
expect fun platformWebviewConfig(webView: WebViewState)
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.webview
import io.github.kdroidfilter.webview.web.WebViewState
expect fun platformWebviewConfig(webView: WebViewState, pluginId: String?)
expect suspend fun platformClearWebviewData(pluginId: String?)

View File

@ -1,228 +1,222 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.webview
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
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.rememberWebViewNavigator
import io.github.kdroidfilter.webview.web.WebViewState
import io.github.kdroidfilter.webview.web.WebViewNavigator
import compose.icons.FeatherIcons
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 kotlinx.coroutines.launch
class PostMessageHandler(
private val onMessageReceived: (String) -> Unit = {}
) : IJsMessageHandler {
override fun methodName(): String {
return "sendMessage"
}
override fun handle(
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
) {
onMessageReceived(message.params)
callback(message.params)
}
}
@Composable
fun PlatformWebViewScreen(webViewController: WebViewController) {
if (webViewController.getContent() == null) {
// This should never happen, but just in case
Text("No URL to load")
return
}
val state = remember {
WebViewState(
webViewController.getWebContent(
additionalHttpHeaders = mapOf(
"User-Agent" to UserAgents.random()
)
)
)
}.apply {
this.content = webViewController.getWebContent()
platformWebviewConfig(this)
}
val navigator = rememberWebViewNavigator()
val webViewBridge = rememberWebViewJsBridge(navigator)
val bridgeBootstrapScript = remember {
"""
(function() {
if (typeof window.sendMessage !== "function") {
window.sendMessage = function(message) {
if (typeof message !== "string") {
throw new TypeError("[window.sendMessage] Message must be a string");
}
window.kmpJsBridge.callNative("sendMessage", message);
};
}
if (!window.bridgeReady) {
const event = new CustomEvent("onBridgeReady");
window.dispatchEvent(event);
window.bridgeReady = true;
}
})();
""".trimIndent()
}
LaunchedEffect(state) {
snapshotFlow { state.lastLoadedUrl }.collect { url ->
if (url != null) {
webViewController.emitUrlChange(url)
navigator.evaluateJavaScript(bridgeBootstrapScript)
webViewController.emitWebViewCreated()
}
}
}
LaunchedEffect(state.cookieManager, navigator) {
webViewController.setCookieManager(cookieManager = state.cookieManager)
webViewController.webViewNavigator = navigator
}
LaunchedEffect(webViewBridge) {
webViewBridge.register(PostMessageHandler { message ->
webViewController.emitPostMessage(message)
})
}
DisposableEffect(Unit) {
onDispose {
webViewController.dispose()
}
}
Scaffold(
contentWindowInsets = WindowInsets.statusBars,
topBar = {
Row(
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier.height(56.dp)
) {
IconButton(
onClick = {
navigator.navigateBack()
}, enabled = navigator.canGoBack
) {
Icon(
FeatherIcons.ChevronLeft,
contentDescription = "Go back to browser history"
)
}
IconButton(
onClick = {
navigator.navigateForward()
}, enabled = navigator.canGoForward
) {
Icon(
FeatherIcons.ChevronRight,
contentDescription = "Go forward to browser history"
)
}
}
Surface(
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
) {
BasicTextField(
value = state.lastLoadedUrl ?: "",
onValueChange = {}, // Read-only
readOnly = true,
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Start
),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
.wrapContentHeight(Alignment.CenterVertically)
)
}
IconButton(
onClick = {
webViewController.closeWebview()
}) {
Icon(FeatherIcons.X, contentDescription = "Close WebView")
}
}
}) { innerPadding ->
WebView(
state = state,
modifier = Modifier.padding(innerPadding).fillMaxSize(),
navigator = navigator,
webViewJsBridge = webViewBridge,
onCreated = { webView ->
navigator.evaluateJavaScript(bridgeBootstrapScript)
},
factory = null
)
}
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.webview
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import compose.icons.FeatherIcons
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
class PostMessageHandler(
private val onMessageReceived: (String) -> Unit = {}
) : IJsMessageHandler {
override fun methodName(): String {
return "sendMessage"
}
override fun handle(
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
) {
onMessageReceived(message.params)
callback(message.params)
}
}
@Composable
fun PlatformWebViewScreen(webViewController: WebViewController) {
if (webViewController.getContent() == null) {
// This should never happen, but just in case
Text("No URL to load")
return
}
val state = remember {
WebViewState(
webViewController.getWebContent(
additionalHttpHeaders = mapOf(
"User-Agent" to UserAgents.random()
)
)
)
}.apply {
this.content = webViewController.getWebContent()
platformWebviewConfig(this, webViewController.currentPluginId)
}
val navigator = rememberWebViewNavigator()
val webViewBridge = rememberWebViewJsBridge(navigator)
val bridgeBootstrapScript = remember {
"""
(function() {
if (typeof window.sendMessage !== "function") {
window.sendMessage = function(message) {
if (typeof message !== "string") {
throw new TypeError("[window.sendMessage] Message must be a string");
}
window.kmpJsBridge.callNative("sendMessage", message);
};
}
if (!window.bridgeReady) {
const event = new CustomEvent("onBridgeReady");
window.dispatchEvent(event);
window.bridgeReady = true;
}
})();
""".trimIndent()
}
LaunchedEffect(state) {
snapshotFlow { state.lastLoadedUrl }.collect { url ->
if (url != null) {
webViewController.emitUrlChange(url)
navigator.evaluateJavaScript(bridgeBootstrapScript)
webViewController.emitWebViewCreated()
}
}
}
LaunchedEffect(state.cookieManager, navigator) {
webViewController.setCookieManager(cookieManager = state.cookieManager)
webViewController.webViewNavigator = navigator
}
LaunchedEffect(webViewBridge) {
webViewBridge.register(PostMessageHandler { message ->
webViewController.emitPostMessage(message)
})
}
DisposableEffect(Unit) {
onDispose {
webViewController.dispose()
}
}
Scaffold(
contentWindowInsets = WindowInsets.statusBars,
topBar = {
Row(
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier.height(56.dp)
) {
IconButton(
onClick = {
navigator.navigateBack()
}, enabled = navigator.canGoBack
) {
Icon(
FeatherIcons.ChevronLeft,
contentDescription = "Go back to browser history"
)
}
IconButton(
onClick = {
navigator.navigateForward()
}, enabled = navigator.canGoForward
) {
Icon(
FeatherIcons.ChevronRight,
contentDescription = "Go forward to browser history"
)
}
}
Surface(
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
) {
BasicTextField(
value = state.lastLoadedUrl ?: "",
onValueChange = {}, // Read-only
readOnly = true,
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Start
),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
.wrapContentHeight(Alignment.CenterVertically)
)
}
IconButton(
onClick = {
webViewController.closeWebview()
}) {
Icon(FeatherIcons.X, contentDescription = "Close WebView")
}
}
}) { innerPadding ->
WebView(
state = state,
modifier = Modifier.padding(innerPadding).fillMaxSize(),
navigator = navigator,
webViewJsBridge = webViewBridge,
onCreated = { webView ->
navigator.evaluateJavaScript(bridgeBootstrapScript)
},
factory = null
)
}
}

View File

@ -1,345 +1,345 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.zipline
import app.cash.zipline.Zipline
import app.cash.zipline.ZiplineService
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
import app.cash.zipline.loader.LoadResult
import app.cash.zipline.loader.ManifestVerifier
import app.cash.zipline.loader.ZiplineLoader
import dev.krtirtho.plugin_interfaces.core.Initializer
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
import dev.krtirtho.spotube.modules.plugin.PluginAbility
import dev.krtirtho.spotube.modules.plugin.PluginCapability
import dev.krtirtho.spotube.modules.plugin.PluginEntry
import io.ktor.http.URLBuilder
import io.ktor.http.decodeURLQueryComponent
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okio.Path.Companion.toPath
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.reflect.KClass
/**
* Manages the lifecycle of a Zipline plugin.
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
* Anything it provides, must be called within that dispatcher context.
* The [use] function must be used.
*
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
*
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
*/
open class ZiplinePluginService(
val applicationName: String,
private val manifestUrl: String,
private val pluginInfo: PluginEntry,
) : PluginService, KoinComponent {
// QuickJS compile() uses deep C-level recursion on the native thread stack.
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
// We use a custom EventListener to increase maxStackSize right after the Zipline
// instance is created, before any modules are loaded.
private val ziplineDispatcher = createZiplineDispatcher()
private fun trace(event: String) {
logger.d { "[$applicationName] $event" }
}
private val logger by injectLogger<ZiplinePluginService>()
private val webViewController: WebViewController by inject()
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
logger.e(throwable) { "Zipline Engine Error" }
}
private val lifecycleMutex = Mutex()
private var ziplineLoader: ZiplineLoader
private var ziplineInstance: Zipline? = null
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
init {
val manifestPath = URLBuilder(manifestUrl)
val baseDir =
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
ziplineLoader = ZiplineLoader(
dispatcher = ziplineDispatcher.dispatcher,
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
httpClient = FileSystemHTTPClient(baseDir)
)
}
private val realHttpClientAPI = RealHttpClientAPI()
private val realWebViewAPI = RealWebViewAPI(scope, webViewController)
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
private val systemInformationAPI = RealSystemInformationAPI()
private val loggedInStateFlow = MutableStateFlow(false)
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
private fun bindHostServices(zipline: Zipline) {
trace("initializer(): binding host APIs")
logger.d { "[$applicationName] Binding host APIs in initializer" }
try {
// Basic APIs
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
zipline.bind<SystemInformationAPI>(
SystemInformationAPI_SERVICE_NAME,
systemInformationAPI
)
// Conditional APIs based on plugin capabilities
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
zipline.bind<HttpClientAPI>(
HttpClientAPI_SERVICE_NAME,
realHttpClientAPI
)
}
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
}
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
zipline.bind<PersistedStorageAPI>(
PersistedStorageAPI_SERVICE_NAME,
persistedStorageAPI
)
}
} catch (e: Exception) {
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
throw e
}
}
private fun consumePluginServices(result: LoadResult.Success) {
trace("start(): loadOnce success")
val apiMap =
buildMap<KClass<*>, ZiplineService> {
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
if (PluginAbility.METADATA in pluginInfo.abilities) {
put(
MetadataUserAPI::class,
result.zipline.take<MetadataUserAPI>(
MetadataUserAPI_SERVICE_NAME
)
)
put(
MetadataTrackAPI::class,
result.zipline.take<MetadataTrackAPI>(
MetadataTrackAPI_SERVICE_NAME
)
)
put(
MetadataAlbumAPI::class,
result.zipline.take<MetadataAlbumAPI>(
MetadataAlbumAPI_SERVICE_NAME
)
)
put(
MetadataArtistAPI::class,
result.zipline.take<MetadataArtistAPI>(
MetadataArtistAPI_SERVICE_NAME
)
)
put(
MetadataPlaylistAPI::class,
result.zipline.take<MetadataPlaylistAPI>(
MetadataPlaylistAPI_SERVICE_NAME
)
)
put(
MetadataBrowseAPI::class,
result.zipline.take<MetadataBrowseAPI>(
MetadataBrowseAPI_SERVICE_NAME
)
)
put(
MetadataSearchAPI::class,
result.zipline.take<MetadataSearchAPI>(
MetadataSearchAPI_SERVICE_NAME
)
)
}
if (PluginAbility.AUDIO in pluginInfo.abilities) {
put(
AudioAPI::class,
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
)
}
if (PluginAbility.LYRICS in pluginInfo.abilities) {
put(
LyricsAPI::class,
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
)
}
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
put(
ScrobbleAPI::class,
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
)
}
}
serviceRegistry.putAll(apiMap)
trace("start(): API ready")
}
private fun runLogInFlowObservers() = scope.launch {
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
coreAPI.loggedInFlow.collect { isLoggedIn ->
loggedInStateFlow.value = isLoggedIn
}
}
override suspend fun start() {
lifecycleMutex.withLock {
trace("start(): entered")
if (serviceRegistry.isNotEmpty()) {
trace("start(): already started, skipping")
return
}
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
withContext(ziplineDispatcher.dispatcher) {
trace("start(): inside zipline dispatcher before loadOnce")
val result = ziplineLoader.loadOnce(
applicationName = applicationName,
manifestUrl = manifestUrl,
freshnessChecker = DefaultFreshnessCheckerNotFresh,
)
when (result) {
is LoadResult.Success -> {
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
ziplineInstance = result.zipline
// Now we consume the initializer
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
// Bind host services before initialization, so plugins can use them in their initializer
bindHostServices(result.zipline)
trace("start(): calling initializer.initialize()")
runCatching { initializer.initialize() }
.onSuccess {
consumePluginServices(result)
runLogInFlowObservers()
}
.onFailure { e ->
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
throw e
}
}
is LoadResult.Failure -> {
trace("start(): loadOnce failure: ${result.exception}")
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
throw result.exception
}
}
}
}
}
override suspend fun stop() {
lifecycleMutex.withLock {
trace("stop(): entered")
withContext(ziplineDispatcher.dispatcher) {
ziplineInstance?.close()
ziplineInstance = null
for (service in serviceRegistry.values) {
try {
trace("stop(): closing service ${service::class.simpleName}")
service.close()
} catch (_: Exception) {
trace("stop(): error closing service ${service::class.simpleName}")
}
}
}
serviceRegistry.clear()
scope.cancel()
loggedInStateFlow.value = false
ziplineDispatcher.close()
trace("stop(): completed")
}
}
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
// Create the scope with the current registry
val scope = PluginServiceScope(serviceRegistry)
// Execute the block with 'scope' as 'this'
scope.block()
}
}
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.zipline
import app.cash.zipline.Zipline
import app.cash.zipline.ZiplineService
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
import app.cash.zipline.loader.LoadResult
import app.cash.zipline.loader.ManifestVerifier
import app.cash.zipline.loader.ZiplineLoader
import dev.krtirtho.plugin_interfaces.core.Initializer
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
import dev.krtirtho.spotube.modules.plugin.PluginAbility
import dev.krtirtho.spotube.modules.plugin.PluginCapability
import dev.krtirtho.spotube.modules.plugin.PluginEntry
import io.ktor.http.URLBuilder
import io.ktor.http.decodeURLQueryComponent
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okio.Path.Companion.toPath
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.reflect.KClass
/**
* Manages the lifecycle of a Zipline plugin.
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
* Anything it provides, must be called within that dispatcher context.
* The [use] function must be used.
*
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
*
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
*/
open class ZiplinePluginService(
val applicationName: String,
private val manifestUrl: String,
private val pluginInfo: PluginEntry,
) : PluginService, KoinComponent {
// QuickJS compile() uses deep C-level recursion on the native thread stack.
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
// We use a custom EventListener to increase maxStackSize right after the Zipline
// instance is created, before any modules are loaded.
private val ziplineDispatcher = createZiplineDispatcher()
private fun trace(event: String) {
logger.d { "[$applicationName] $event" }
}
private val logger by injectLogger<ZiplinePluginService>()
private val webViewController: WebViewController by inject()
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
logger.e(throwable) { "Zipline Engine Error" }
}
private val lifecycleMutex = Mutex()
private var ziplineLoader: ZiplineLoader
private var ziplineInstance: Zipline? = null
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
init {
val manifestPath = URLBuilder(manifestUrl)
val baseDir =
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
ziplineLoader = ZiplineLoader(
dispatcher = ziplineDispatcher.dispatcher,
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
httpClient = FileSystemHTTPClient(baseDir)
)
}
private val realHttpClientAPI = RealHttpClientAPI()
private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
private val systemInformationAPI = RealSystemInformationAPI()
private val loggedInStateFlow = MutableStateFlow(false)
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
private fun bindHostServices(zipline: Zipline) {
trace("initializer(): binding host APIs")
logger.d { "[$applicationName] Binding host APIs in initializer" }
try {
// Basic APIs
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
zipline.bind<SystemInformationAPI>(
SystemInformationAPI_SERVICE_NAME,
systemInformationAPI
)
// Conditional APIs based on plugin capabilities
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
zipline.bind<HttpClientAPI>(
HttpClientAPI_SERVICE_NAME,
realHttpClientAPI
)
}
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
}
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
zipline.bind<PersistedStorageAPI>(
PersistedStorageAPI_SERVICE_NAME,
persistedStorageAPI
)
}
} catch (e: Exception) {
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
throw e
}
}
private fun consumePluginServices(result: LoadResult.Success) {
trace("start(): loadOnce success")
val apiMap =
buildMap<KClass<*>, ZiplineService> {
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
if (PluginAbility.METADATA in pluginInfo.abilities) {
put(
MetadataUserAPI::class,
result.zipline.take<MetadataUserAPI>(
MetadataUserAPI_SERVICE_NAME
)
)
put(
MetadataTrackAPI::class,
result.zipline.take<MetadataTrackAPI>(
MetadataTrackAPI_SERVICE_NAME
)
)
put(
MetadataAlbumAPI::class,
result.zipline.take<MetadataAlbumAPI>(
MetadataAlbumAPI_SERVICE_NAME
)
)
put(
MetadataArtistAPI::class,
result.zipline.take<MetadataArtistAPI>(
MetadataArtistAPI_SERVICE_NAME
)
)
put(
MetadataPlaylistAPI::class,
result.zipline.take<MetadataPlaylistAPI>(
MetadataPlaylistAPI_SERVICE_NAME
)
)
put(
MetadataBrowseAPI::class,
result.zipline.take<MetadataBrowseAPI>(
MetadataBrowseAPI_SERVICE_NAME
)
)
put(
MetadataSearchAPI::class,
result.zipline.take<MetadataSearchAPI>(
MetadataSearchAPI_SERVICE_NAME
)
)
}
if (PluginAbility.AUDIO in pluginInfo.abilities) {
put(
AudioAPI::class,
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
)
}
if (PluginAbility.LYRICS in pluginInfo.abilities) {
put(
LyricsAPI::class,
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
)
}
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
put(
ScrobbleAPI::class,
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
)
}
}
serviceRegistry.putAll(apiMap)
trace("start(): API ready")
}
private fun runLogInFlowObservers() = scope.launch {
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
coreAPI.loggedInFlow.collect { isLoggedIn ->
loggedInStateFlow.value = isLoggedIn
}
}
override suspend fun start() {
lifecycleMutex.withLock {
trace("start(): entered")
if (serviceRegistry.isNotEmpty()) {
trace("start(): already started, skipping")
return
}
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
withContext(ziplineDispatcher.dispatcher) {
trace("start(): inside zipline dispatcher before loadOnce")
val result = ziplineLoader.loadOnce(
applicationName = applicationName,
manifestUrl = manifestUrl,
freshnessChecker = DefaultFreshnessCheckerNotFresh,
)
when (result) {
is LoadResult.Success -> {
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
ziplineInstance = result.zipline
// Now we consume the initializer
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
// Bind host services before initialization, so plugins can use them in their initializer
bindHostServices(result.zipline)
trace("start(): calling initializer.initialize()")
runCatching { initializer.initialize() }
.onSuccess {
consumePluginServices(result)
runLogInFlowObservers()
}
.onFailure { e ->
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
throw e
}
}
is LoadResult.Failure -> {
trace("start(): loadOnce failure: ${result.exception}")
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
throw result.exception
}
}
}
}
}
override suspend fun stop() {
lifecycleMutex.withLock {
trace("stop(): entered")
withContext(ziplineDispatcher.dispatcher) {
ziplineInstance?.close()
ziplineInstance = null
for (service in serviceRegistry.values) {
try {
trace("stop(): closing service ${service::class.simpleName}")
service.close()
} catch (_: Exception) {
trace("stop(): error closing service ${service::class.simpleName}")
}
}
}
serviceRegistry.clear()
scope.cancel()
loggedInStateFlow.value = false
ziplineDispatcher.close()
trace("stop(): completed")
}
}
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
// Create the scope with the current registry
val scope = PluginServiceScope(serviceRegistry)
// Execute the block with 'scope' as 'this'
scope.block()
}
}
}

View File

@ -1,76 +1,74 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.zipline.host_apis
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
import dev.krtirtho.spotube.core.webview.WebViewController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class RealWebViewAPI(
private val scope: CoroutineScope,
private val webViewController: WebViewController,
) : WebViewAPI {
override fun navigateTo(url: String) {
scope.launch(Dispatchers.Main) {
webViewController.navigateTo(url)
}
}
override fun navigateToHTML(html: String) {
scope.launch {
webViewController.navigateToHTML(html)
}
}
override suspend fun getCookies(url: String): List<Cookie> {
return withContext(Dispatchers.Main) {
webViewController.getCookies(url)
}
}
override suspend fun evaluateJavaScript(script: String): String? {
return withContext(Dispatchers.Main) {
webViewController.evaluateJavascript(script)
}
}
override fun urlChangeFlow(): Flow<String> {
return webViewController.urlChangedFlow
}
override fun webviewCreatedFlow(): Flow<Unit> {
return webViewController.webviewCreatedFlow
}
override fun postMessagesFlow(): Flow<String> {
return webViewController.postMessagesFlow
}
override fun exitWebView() {
scope.launch(Dispatchers.Main) {
webViewController.closeWebview()
}
}
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.zipline.host_apis
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
import dev.krtirtho.spotube.core.webview.WebViewController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class RealWebViewAPI(
private val scope: CoroutineScope,
private val webViewController: WebViewController,
private val pluginId: String,
) : WebViewAPI {
override fun navigateTo(url: String) {
scope.launch(Dispatchers.Main) {
webViewController.navigateTo(url, pluginId)
}
}
override fun navigateToHTML(html: String) {
scope.launch {
webViewController.navigateToHTML(html, pluginId)
}
}
override suspend fun getCookies(url: String): List<Cookie> {
return withContext(Dispatchers.Main) {
webViewController.getCookies(url)
}
}
override suspend fun evaluateJavaScript(script: String): String? {
return withContext(Dispatchers.Main) {
webViewController.evaluateJavascript(script)
}
}
override fun urlChangeFlow(): Flow<String> {
return webViewController.urlChangedFlow
}
override fun webviewCreatedFlow(): Flow<Unit> {
return webViewController.webviewCreatedFlow
}
override fun postMessagesFlow(): Flow<String> {
return webViewController.postMessagesFlow
}
override fun exitWebView() {
scope.launch(Dispatchers.Main) {
webViewController.closeWebview()
}
}
}

View File

@ -1,23 +1,41 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.webview
import io.github.kdroidfilter.webview.web.WebViewState
actual fun platformWebviewConfig(webView: WebViewState) {
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.webview
import io.github.kdroidfilter.webview.web.WebViewState
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSDate
import platform.Foundation.NSHTTPCookieStorage
import platform.Foundation.distantPast
import platform.WebKit.WKWebsiteDataStore
import platform.WebKit.WKWebsiteDataTypeCookies
import platform.WebKit.WKWebsiteDataTypeLocalStorage
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
}
@OptIn(ExperimentalForeignApi::class)
actual suspend fun platformClearWebviewData(pluginId: String?) {
if (pluginId == null) return
val dataStore = WKWebsiteDataStore.defaultDataStore()
val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage)
dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {}
NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast)
}

View File

@ -1,31 +1,42 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.webview
import dev.krtirtho.spotube.core.paths.Paths
import io.github.kdroidfilter.webview.web.WebViewState
import io.github.vinceglb.filekit.utils.div
import io.github.vinceglb.filekit.utils.toPath
import org.koin.core.context.GlobalContext
actual fun platformWebviewConfig(webView: WebViewState) {
val paths = GlobalContext.get().get<Paths>()
webView.webSettings.desktopWebSettings.dataDirectory =
(paths.getApplicationCacheDirPath().toPath() / "webview_data").toString()
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.core.webview
import dev.krtirtho.spotube.core.paths.Paths
import io.github.kdroidfilter.webview.web.WebViewState
import okio.FileSystem
import okio.Path.Companion.toPath
import org.koin.core.context.GlobalContext
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
val paths = GlobalContext.get().get<Paths>()
val baseDir = "${paths.getApplicationCacheDirPath()}/webview_data".toPath()
val dataDir = if (pluginId != null) baseDir / pluginId else baseDir
webView.webSettings.desktopWebSettings.dataDirectory = dataDir.toString()
}
actual suspend fun platformClearWebviewData(pluginId: String?) {
if (pluginId == null) return
val paths = GlobalContext.get().get<Paths>()
val dataDirStr = "${paths.getApplicationCacheDirPath()}/webview_data/$pluginId"
val dataDir = dataDirStr.toPath()
if (FileSystem.SYSTEM.exists(dataDir)) {
FileSystem.SYSTEM.deleteRecursively(dataDir)
}
}