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 * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.core.webview package dev.krtirtho.spotube.core.webview
import io.github.kdroidfilter.webview.web.WebViewState import android.webkit.CookieManager
import android.webkit.WebStorage
actual fun platformWebviewConfig(webView: WebViewState) { import android.webkit.WebView
webView.webView?.nativeWebView?.settings?.domStorageEnabled = true 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 * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.core.webview package dev.krtirtho.spotube.core.webview
import io.github.kdroidfilter.webview.web.WebContent import io.github.kdroidfilter.webview.web.WebContent
import io.github.kdroidfilter.webview.web.WebViewNavigator import io.github.kdroidfilter.webview.web.WebViewNavigator
import io.github.kdroidfilter.webview.cookie.CookieManager import io.github.kdroidfilter.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
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
class WebViewController(val navigationCommands: NavigationCommands): KoinComponent { class WebViewController(val navigationCommands: NavigationCommands): KoinComponent {
private val logger by injectLogger<WebViewController>() private val logger by injectLogger<WebViewController>()
private var cookieManager: CookieManager? = null private var cookieManager: CookieManager? = null
private val urlFlow = MutableStateFlow("") private val urlFlow = MutableStateFlow("")
private val webViewCreated = MutableSharedFlow<Unit>(replay = 1) private val webViewCreated = MutableSharedFlow<Unit>(replay = 1)
var currentPluginId: String? = null
suspend fun getCookies(url: String): List<Cookie> { private set
if (cookieManager == null) {
logger.w { "CookieManager is not initialized. Returning empty cookie list." } suspend fun getCookies(url: String): List<Cookie> {
return emptyList() if (cookieManager == null) {
} logger.w { "CookieManager is not initialized. Returning empty cookie list." }
val cookies = cookieManager!!.getCookies(url) return emptyList()
val cookieList = mutableListOf<Cookie>() }
cookies.forEach { val cookies = cookieManager!!.getCookies(url)
cookieList.add( val cookieList = mutableListOf<Cookie>()
Cookie( cookies.forEach {
name = it.name, cookieList.add(
value = it.value, Cookie(
domain = it.domain ?: "", name = it.name,
path = it.path, value = it.value,
expiresAt = it.expiresDate, domain = it.domain ?: "",
secure = it.isSecure ?: false, path = it.path,
httpOnly = it.isHttpOnly ?: false expiresAt = it.expiresDate,
) secure = it.isSecure ?: false,
) httpOnly = it.isHttpOnly ?: false
} )
return cookieList )
} }
return cookieList
private var content: String? = null }
private var isHtmlContent: Boolean = false
fun getContent(): String? = content private var content: String? = null
fun getWebContent(additionalHttpHeaders: Map<String, String> = emptyMap()): WebContent { private var isHtmlContent: Boolean = false
if (content == null) throw IllegalStateException("Content is null. This should not happen as WebView should only be opened when content is set.") fun getContent(): String? = content
if (isHtmlContent) return WebContent.Data(data = content!!, mimeType = "text/html") fun getWebContent(additionalHttpHeaders: Map<String, String> = emptyMap()): WebContent {
return WebContent.Url(url = content!!, additionalHttpHeaders = additionalHttpHeaders) 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) { var webViewNavigator: WebViewNavigator? = null
urlFlow.value = url
} fun emitUrlChange(url: String) {
urlFlow.value = url
fun emitWebViewCreated() { }
webViewCreated.tryEmit(Unit)
} fun emitWebViewCreated() {
webViewCreated.tryEmit(Unit)
fun setCookieManager(cookieManager: CookieManager) { }
this.cookieManager = cookieManager
} fun setCookieManager(cookieManager: CookieManager) {
this.cookieManager = cookieManager
@OptIn(ExperimentalCoroutinesApi::class) }
fun closeWebview() {
cookieManager = null @OptIn(ExperimentalCoroutinesApi::class)
content = null fun closeWebview() {
isHtmlContent = false cookieManager = null
webViewNavigator = null content = null
navigationCommands.pop(Routes.WebView) isHtmlContent = false
urlFlow.value = "" webViewNavigator = null
webViewCreated.resetReplayCache() navigationCommands.pop(Routes.WebView)
_postMessagesFlow.resetReplayCache() urlFlow.value = ""
} webViewCreated.resetReplayCache()
_postMessagesFlow.resetReplayCache()
fun dispose() { }
cookieManager = null
content = null fun dispose() {
isHtmlContent = false cookieManager = null
webViewNavigator = 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.") fun navigateTo(url: String, pluginId: String) {
} if (this.content != null) {
this.content = url throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
this.isHtmlContent = false }
navigationCommands.navigateTo(Routes.WebView) this.currentPluginId = pluginId
} this.content = url
this.isHtmlContent = false
fun navigateToHTML(html: String) { navigationCommands.navigateTo(Routes.WebView)
if (this.content != null) { }
throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
} fun navigateToHTML(html: String, pluginId: String) {
this.content = html if (this.content != null) {
this.isHtmlContent = true throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
navigationCommands.navigateTo(Routes.WebView) }
} this.currentPluginId = pluginId
this.content = html
suspend fun evaluateJavascript(jsCode: String): String? { this.isHtmlContent = true
if (webViewNavigator == null) { navigationCommands.navigateTo(Routes.WebView)
throw IllegalStateException("WebView is not initialized. Cannot evaluate JavaScript.") }
}
val completer = CompletableDeferred<String?>() suspend fun evaluateJavascript(jsCode: String): String? {
try { if (webViewNavigator == null) {
webViewNavigator?.evaluateJavaScript(jsCode) { result -> throw IllegalStateException("WebView is not initialized. Cannot evaluate JavaScript.")
completer.complete(result) }
} val completer = CompletableDeferred<String?>()
} catch (e: Exception) { try {
completer.completeExceptionally(e) webViewNavigator?.evaluateJavaScript(jsCode) { result ->
throw e completer.complete(result)
} }
return completer.await() } catch (e: Exception) {
} completer.completeExceptionally(e)
throw e
suspend fun clearData() { }
cookieManager?.removeAllCookies() return completer.await()
cookieManager = null }
content = null
isHtmlContent = false suspend fun clearData(pluginId: String? = null) {
webViewNavigator = null cookieManager?.removeAllCookies()
} val targetPluginId = pluginId ?: currentPluginId
platformClearWebviewData(targetPluginId)
val urlChangedFlow = urlFlow.asStateFlow() cookieManager = null
val webviewCreatedFlow = webViewCreated.asSharedFlow() content = null
private val _postMessagesFlow = MutableSharedFlow<String>(replay = 1) isHtmlContent = false
fun emitPostMessage(message: String) { webViewNavigator = null
_postMessagesFlow.tryEmit(message) currentPluginId = null
} }
val postMessagesFlow = _postMessagesFlow.asSharedFlow() 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 * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.core.webview package dev.krtirtho.spotube.core.webview
import io.github.kdroidfilter.webview.web.WebViewState import io.github.kdroidfilter.webview.web.WebViewState
expect fun platformWebviewConfig(webView: 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 * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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 * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.core.webview package dev.krtirtho.spotube.core.webview
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.Icon
import androidx.compose.material3.Icon import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold
import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface
import androidx.compose.material3.Surface import androidx.compose.material3.Text
import androidx.compose.material3.Text import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Alignment
import androidx.compose.runtime.remember import androidx.compose.ui.Modifier
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.setValue import androidx.compose.ui.text.style.TextAlign
import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.unit.dp
import androidx.compose.ui.Alignment import compose.icons.FeatherIcons
import androidx.compose.ui.Modifier import compose.icons.feathericons.ChevronLeft
import androidx.compose.ui.graphics.Color import compose.icons.feathericons.ChevronRight
import androidx.compose.ui.text.style.TextAlign import compose.icons.feathericons.X
import androidx.compose.ui.unit.dp import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler
import io.github.kdroidfilter.webview.jsbridge.JsMessage import io.github.kdroidfilter.webview.jsbridge.JsMessage
import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge
import io.github.kdroidfilter.webview.web.WebView import io.github.kdroidfilter.webview.web.WebView
import io.github.kdroidfilter.webview.web.rememberWebViewNavigator import io.github.kdroidfilter.webview.web.WebViewNavigator
import io.github.kdroidfilter.webview.web.WebViewState import io.github.kdroidfilter.webview.web.WebViewState
import io.github.kdroidfilter.webview.web.WebViewNavigator import io.github.kdroidfilter.webview.web.rememberWebViewNavigator
import compose.icons.FeatherIcons
import compose.icons.feathericons.ChevronLeft class PostMessageHandler(
import compose.icons.feathericons.ChevronRight private val onMessageReceived: (String) -> Unit = {}
import compose.icons.feathericons.X ) : IJsMessageHandler {
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents override fun methodName(): String {
import kotlinx.coroutines.launch return "sendMessage"
}
class PostMessageHandler(
private val onMessageReceived: (String) -> Unit = {} override fun handle(
) : IJsMessageHandler { message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
override fun methodName(): String { ) {
return "sendMessage" onMessageReceived(message.params)
} callback(message.params)
}
override fun handle( }
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
) { @Composable
onMessageReceived(message.params) fun PlatformWebViewScreen(webViewController: WebViewController) {
callback(message.params) if (webViewController.getContent() == null) {
} // This should never happen, but just in case
} Text("No URL to load")
return
@Composable }
fun PlatformWebViewScreen(webViewController: WebViewController) {
if (webViewController.getContent() == null) { val state = remember {
// This should never happen, but just in case WebViewState(
Text("No URL to load") webViewController.getWebContent(
return additionalHttpHeaders = mapOf(
} "User-Agent" to UserAgents.random()
)
val state = remember { )
WebViewState( )
webViewController.getWebContent( }.apply {
additionalHttpHeaders = mapOf( this.content = webViewController.getWebContent()
"User-Agent" to UserAgents.random() platformWebviewConfig(this, webViewController.currentPluginId)
) }
)
) val navigator = rememberWebViewNavigator()
}.apply { val webViewBridge = rememberWebViewJsBridge(navigator)
this.content = webViewController.getWebContent()
platformWebviewConfig(this) val bridgeBootstrapScript = remember {
} """
(function() {
val navigator = rememberWebViewNavigator() if (typeof window.sendMessage !== "function") {
val webViewBridge = rememberWebViewJsBridge(navigator) window.sendMessage = function(message) {
if (typeof message !== "string") {
val bridgeBootstrapScript = remember { throw new TypeError("[window.sendMessage] Message must be a string");
""" }
(function() { window.kmpJsBridge.callNative("sendMessage", message);
if (typeof window.sendMessage !== "function") { };
window.sendMessage = function(message) { }
if (typeof message !== "string") {
throw new TypeError("[window.sendMessage] Message must be a string"); if (!window.bridgeReady) {
} const event = new CustomEvent("onBridgeReady");
window.kmpJsBridge.callNative("sendMessage", message); window.dispatchEvent(event);
}; window.bridgeReady = true;
} }
})();
if (!window.bridgeReady) { """.trimIndent()
const event = new CustomEvent("onBridgeReady"); }
window.dispatchEvent(event);
window.bridgeReady = true; LaunchedEffect(state) {
} snapshotFlow { state.lastLoadedUrl }.collect { url ->
})(); if (url != null) {
""".trimIndent() webViewController.emitUrlChange(url)
} navigator.evaluateJavaScript(bridgeBootstrapScript)
webViewController.emitWebViewCreated()
LaunchedEffect(state) { }
snapshotFlow { state.lastLoadedUrl }.collect { url -> }
if (url != null) { }
webViewController.emitUrlChange(url)
navigator.evaluateJavaScript(bridgeBootstrapScript) LaunchedEffect(state.cookieManager, navigator) {
webViewController.emitWebViewCreated() webViewController.setCookieManager(cookieManager = state.cookieManager)
} webViewController.webViewNavigator = navigator
} }
}
LaunchedEffect(webViewBridge) {
LaunchedEffect(state.cookieManager, navigator) { webViewBridge.register(PostMessageHandler { message ->
webViewController.setCookieManager(cookieManager = state.cookieManager) webViewController.emitPostMessage(message)
webViewController.webViewNavigator = navigator })
} }
LaunchedEffect(webViewBridge) { DisposableEffect(Unit) {
webViewBridge.register(PostMessageHandler { message -> onDispose {
webViewController.emitPostMessage(message) webViewController.dispose()
}) }
} }
DisposableEffect(Unit) { Scaffold(
onDispose { contentWindowInsets = WindowInsets.statusBars,
webViewController.dispose() topBar = {
} Row(
} modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
horizontalArrangement = Arrangement.SpaceBetween,
Scaffold( verticalAlignment = Alignment.CenterVertically
contentWindowInsets = WindowInsets.statusBars, ) {
topBar = { Row(
Row( verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp), horizontalArrangement = Arrangement.Start,
horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.height(56.dp)
verticalAlignment = Alignment.CenterVertically ) {
) { IconButton(
Row( onClick = {
verticalAlignment = Alignment.CenterVertically, navigator.navigateBack()
horizontalArrangement = Arrangement.Start, }, enabled = navigator.canGoBack
modifier = Modifier.height(56.dp) ) {
) { Icon(
IconButton( FeatherIcons.ChevronLeft,
onClick = { contentDescription = "Go back to browser history"
navigator.navigateBack() )
}, enabled = navigator.canGoBack }
) { IconButton(
Icon( onClick = {
FeatherIcons.ChevronLeft, navigator.navigateForward()
contentDescription = "Go back to browser history" }, enabled = navigator.canGoForward
) ) {
} Icon(
IconButton( FeatherIcons.ChevronRight,
onClick = { contentDescription = "Go forward to browser history"
navigator.navigateForward() )
}, enabled = navigator.canGoForward }
) { }
Icon( Surface(
FeatherIcons.ChevronRight, modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
contentDescription = "Go forward to browser history" shape = RoundedCornerShape(18.dp),
) color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
} border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
} ) {
Surface( BasicTextField(
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp), value = state.lastLoadedUrl ?: "",
shape = RoundedCornerShape(18.dp), onValueChange = {}, // Read-only
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), readOnly = true,
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)) singleLine = true,
) { textStyle = MaterialTheme.typography.bodyMedium.copy(
BasicTextField( color = MaterialTheme.colorScheme.onSurface,
value = state.lastLoadedUrl ?: "", textAlign = TextAlign.Start
onValueChange = {}, // Read-only ),
readOnly = true, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
singleLine = true, .wrapContentHeight(Alignment.CenterVertically)
textStyle = MaterialTheme.typography.bodyMedium.copy( )
color = MaterialTheme.colorScheme.onSurface, }
textAlign = TextAlign.Start IconButton(
), onClick = {
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp) webViewController.closeWebview()
.wrapContentHeight(Alignment.CenterVertically) }) {
) Icon(FeatherIcons.X, contentDescription = "Close WebView")
} }
IconButton( }
onClick = { }) { innerPadding ->
webViewController.closeWebview() WebView(
}) { state = state,
Icon(FeatherIcons.X, contentDescription = "Close WebView") modifier = Modifier.padding(innerPadding).fillMaxSize(),
} navigator = navigator,
} webViewJsBridge = webViewBridge,
}) { innerPadding -> onCreated = { webView ->
WebView( navigator.evaluateJavaScript(bridgeBootstrapScript)
state = state,
modifier = Modifier.padding(innerPadding).fillMaxSize(), },
navigator = navigator, factory = null
webViewJsBridge = webViewBridge, )
onCreated = { webView -> }
navigator.evaluateJavaScript(bridgeBootstrapScript)
},
factory = null
)
}
} }

View File

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

View File

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

View File

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