From fe0ebb47fecf099c646f5c73af8c6ecaff3e661b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 30 Jul 2026 22:53:15 +0600 Subject: [PATCH] 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. --- .../webview/PlatformWebviewConfig.android.kt | 58 +- .../spotube/core/webview/PlatformWebView.kt | 319 ++- .../core/webview/PlatformWebviewConfig.kt | 46 +- .../core/webview/PlatformWebviewScreen.kt | 448 ++- .../core/zipline/ZiplinePluginService.kt | 688 ++--- .../core/zipline/host_apis/RealWebViewAPI.kt | 148 +- .../spotube/modules/plugin/PluginScreen.kt | 2548 ++++++++--------- .../core/webview/PlatformWebviewConfig.ios.kt | 62 +- .../core/webview/PlatformWebviewConfig.jvm.kt | 71 +- 9 files changed, 2215 insertions(+), 2173 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt index 9fabbdfb..50bef4af 100644 --- a/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.android.kt @@ -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 . - */ - -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 . + */ + +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() } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt index 98870847..1dc64f24 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebView.kt @@ -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 . - */ - -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() - private var cookieManager: CookieManager? = null - private val urlFlow = MutableStateFlow("") - private val webViewCreated = MutableSharedFlow(replay = 1) - - suspend fun getCookies(url: String): List { - if (cookieManager == null) { - logger.w { "CookieManager is not initialized. Returning empty cookie list." } - return emptyList() - } - val cookies = cookieManager!!.getCookies(url) - val cookieList = mutableListOf() - 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 = 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() - 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(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 . + */ + +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() + private var cookieManager: CookieManager? = null + private val urlFlow = MutableStateFlow("") + private val webViewCreated = MutableSharedFlow(replay = 1) + var currentPluginId: String? = null + private set + + suspend fun getCookies(url: String): List { + if (cookieManager == null) { + logger.w { "CookieManager is not initialized. Returning empty cookie list." } + return emptyList() + } + val cookies = cookieManager!!.getCookies(url) + val cookieList = mutableListOf() + 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 = 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() + 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(replay = 1) + fun emitPostMessage(message: String) { + _postMessagesFlow.tryEmit(message) + } + + val postMessagesFlow = _postMessagesFlow.asSharedFlow() } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt index 66fc7a0b..39d345f1 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.kt @@ -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 . - */ - -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 . + */ + +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?) diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt index 5c10a031..7aeba292 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewScreen.kt @@ -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 . - */ - -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 . + */ + +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 + ) + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt index 6233d83c..d055b64e 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/ZiplinePluginService.kt @@ -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 . - */ - -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() - 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, 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 = 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_SERVICE_NAME, cryptoAPI) - zipline.bind( - SystemInformationAPI_SERVICE_NAME, - systemInformationAPI - ) - - // Conditional APIs based on plugin capabilities - if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) { - zipline.bind( - HttpClientAPI_SERVICE_NAME, - realHttpClientAPI - ) - } - if (PluginCapability.WEBVIEW in pluginInfo.capabilities) { - zipline.bind(WebViewAPI_SERVICE_NAME, realWebViewAPI) - } - if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) { - zipline.bind( - 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, ZiplineService> { - put(CoreAPI::class, result.zipline.take(CoreAPI_SERVICE_NAME)) - - if (PluginAbility.METADATA in pluginInfo.abilities) { - put( - MetadataUserAPI::class, - result.zipline.take( - MetadataUserAPI_SERVICE_NAME - ) - ) - put( - MetadataTrackAPI::class, - result.zipline.take( - MetadataTrackAPI_SERVICE_NAME - ) - ) - put( - MetadataAlbumAPI::class, - result.zipline.take( - MetadataAlbumAPI_SERVICE_NAME - ) - ) - put( - MetadataArtistAPI::class, - result.zipline.take( - MetadataArtistAPI_SERVICE_NAME - ) - ) - put( - MetadataPlaylistAPI::class, - result.zipline.take( - MetadataPlaylistAPI_SERVICE_NAME - ) - ) - put( - MetadataBrowseAPI::class, - result.zipline.take( - MetadataBrowseAPI_SERVICE_NAME - ) - ) - put( - MetadataSearchAPI::class, - result.zipline.take( - MetadataSearchAPI_SERVICE_NAME - ) - ) - } - if (PluginAbility.AUDIO in pluginInfo.abilities) { - put( - AudioAPI::class, - result.zipline.take(AudioAPI_SERVICE_NAME) - ) - } - if (PluginAbility.LYRICS in pluginInfo.abilities) { - put( - LyricsAPI::class, - result.zipline.take(LyricsAPI_SERVICE_NAME) - ) - } - if (PluginAbility.SCROBBLE in pluginInfo.abilities) { - put( - ScrobbleAPI::class, - result.zipline.take(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_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 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 . + */ + +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() + 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, 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 = 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_SERVICE_NAME, cryptoAPI) + zipline.bind( + SystemInformationAPI_SERVICE_NAME, + systemInformationAPI + ) + + // Conditional APIs based on plugin capabilities + if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) { + zipline.bind( + HttpClientAPI_SERVICE_NAME, + realHttpClientAPI + ) + } + if (PluginCapability.WEBVIEW in pluginInfo.capabilities) { + zipline.bind(WebViewAPI_SERVICE_NAME, realWebViewAPI) + } + if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) { + zipline.bind( + 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, ZiplineService> { + put(CoreAPI::class, result.zipline.take(CoreAPI_SERVICE_NAME)) + + if (PluginAbility.METADATA in pluginInfo.abilities) { + put( + MetadataUserAPI::class, + result.zipline.take( + MetadataUserAPI_SERVICE_NAME + ) + ) + put( + MetadataTrackAPI::class, + result.zipline.take( + MetadataTrackAPI_SERVICE_NAME + ) + ) + put( + MetadataAlbumAPI::class, + result.zipline.take( + MetadataAlbumAPI_SERVICE_NAME + ) + ) + put( + MetadataArtistAPI::class, + result.zipline.take( + MetadataArtistAPI_SERVICE_NAME + ) + ) + put( + MetadataPlaylistAPI::class, + result.zipline.take( + MetadataPlaylistAPI_SERVICE_NAME + ) + ) + put( + MetadataBrowseAPI::class, + result.zipline.take( + MetadataBrowseAPI_SERVICE_NAME + ) + ) + put( + MetadataSearchAPI::class, + result.zipline.take( + MetadataSearchAPI_SERVICE_NAME + ) + ) + } + if (PluginAbility.AUDIO in pluginInfo.abilities) { + put( + AudioAPI::class, + result.zipline.take(AudioAPI_SERVICE_NAME) + ) + } + if (PluginAbility.LYRICS in pluginInfo.abilities) { + put( + LyricsAPI::class, + result.zipline.take(LyricsAPI_SERVICE_NAME) + ) + } + if (PluginAbility.SCROBBLE in pluginInfo.abilities) { + put( + ScrobbleAPI::class, + result.zipline.take(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_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 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() + } + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt index 48e28b4b..80f906cb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/host_apis/RealWebViewAPI.kt @@ -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 . - */ - -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 { - 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 { - return webViewController.urlChangedFlow - } - override fun webviewCreatedFlow(): Flow { - return webViewController.webviewCreatedFlow - } - override fun postMessagesFlow(): Flow { - 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 . + */ + +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 { + 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 { + return webViewController.urlChangedFlow + } + override fun webviewCreatedFlow(): Flow { + return webViewController.webviewCreatedFlow + } + override fun postMessagesFlow(): Flow { + return webViewController.postMessagesFlow + } + + override fun exitWebView() { + scope.launch(Dispatchers.Main) { + webViewController.closeWebview() + } + } + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt index 307011c5..f01f5266 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/plugin/PluginScreen.kt @@ -1,1274 +1,1274 @@ -/* - * 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 . - */ - -package dev.krtirtho.spotube.modules.plugin - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -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.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -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.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest -import coil3.request.crossfade -import dev.krtirtho.spotube.PlatformType -import dev.krtirtho.spotube.core.extras.kebabToTitleCase -import dev.krtirtho.spotube.core.ui.base.Card -import dev.krtirtho.spotube.core.ui.base.OutlineButton -import dev.krtirtho.spotube.core.ui.base.PrimaryButton -import dev.krtirtho.spotube.core.ui.base.SecondaryButton -import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton -import dev.krtirtho.spotube.core.ui.base.TextField -import dev.krtirtho.spotube.core.ui.base.ThemedDialog -import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet -import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet -import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem -import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar -import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode -import dev.krtirtho.spotube.core.webview.WebViewController -import dev.krtirtho.spotube.getPlatform -import dev.krtirtho.spotube.modules.plugin.components.PluginCard -import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog -import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog -import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset -import dev.krtirtho.spotube.openUrlInBrowser -import dev.krtirtho.spotube.resources.iconsax.CarbonGithubLogo -import dev.krtirtho.spotube.resources.iconsax.Iconsax -import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd -import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 -import dev.krtirtho.spotube.resources.iconsax.IconsaxBox -import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle -import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText -import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit -import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk -import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe -import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart -import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk -import dev.krtirtho.spotube.resources.iconsax.IconsaxLink -import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic -import dev.krtirtho.spotube.resources.iconsax.IconsaxSound -import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft -import io.github.vinceglb.filekit.dialogs.FileKitType -import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher -import io.github.vinceglb.filekit.readBytes -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.launch -import net.swiftzer.semver.SemVer -import okio.FileSystem -import okio.Path.Companion.toPath -import okio.SYSTEM -import org.jetbrains.compose.resources.stringResource -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel -import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.plugin_action_download -import spotube.composeapp.generated.resources.plugin_action_install_from_file -import spotube.composeapp.generated.resources.plugin_configure_title -import spotube.composeapp.generated.resources.plugin_empty_subtitle -import spotube.composeapp.generated.resources.plugin_empty_title -import spotube.composeapp.generated.resources.plugin_error_download_failed -import spotube.composeapp.generated.resources.plugin_error_enter_url -import spotube.composeapp.generated.resources.plugin_error_url_scheme -import spotube.composeapp.generated.resources.plugin_install_section_title -import spotube.composeapp.generated.resources.plugin_installed_count -import spotube.composeapp.generated.resources.plugin_installed_plural -import spotube.composeapp.generated.resources.plugin_installed_singular -import spotube.composeapp.generated.resources.plugin_screen_title -import spotube.composeapp.generated.resources.plugin_section_file_title -import spotube.composeapp.generated.resources.plugin_section_install -import spotube.composeapp.generated.resources.plugin_section_url_title -import spotube.composeapp.generated.resources.plugin_url_placeholder -import spotube.composeapp.generated.resources.settings_plugins_ability_audio -import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics -import spotube.composeapp.generated.resources.settings_plugins_ability_metadata -import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble -import spotube.composeapp.generated.resources.settings_plugins_action_change -import spotube.composeapp.generated.resources.settings_plugins_action_select -import spotube.composeapp.generated.resources.settings_plugins_default_ability_title -import spotube.composeapp.generated.resources.settings_plugins_no_plugins -import spotube.composeapp.generated.resources.settings_plugins_no_selection -import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description - -private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube") - -private val VERIFIED_PLUGIN_OWNERS = setOf() - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PluginScreen( - pluginManager: PluginManager, - webviewController: WebViewController = koinInject() -) { - val scope = rememberCoroutineScope() - val platform = remember { getPlatform() } - val pendingPlugin by pluginManager.pendingPlugin.collectAsStateWithLifecycle() - val pluginsState by pluginManager.state.collectAsStateWithLifecycle() - val activeServices by pluginManager.ziplineServices.collectAsStateWithLifecycle() - val shellBottomInset = LocalAppShellBottomInset.current - - var urlInput by remember { mutableStateOf("") } - var urlError by remember { mutableStateOf(null) } - var isLoadingUrl by remember { mutableStateOf(false) } - var showInstallSheet by remember { mutableStateOf(false) } - - val discoverViewModel: PluginDiscoverViewModel = koinViewModel() - val discoverState by discoverViewModel.state.collectAsStateWithLifecycle() - - var showPluginInfo by remember { mutableStateOf(null) } - var showPluginSupport by remember { mutableStateOf(null) } - var supportText by remember { mutableStateOf(null) } - var isLoadingSupport by remember { mutableStateOf(false) } - - var installDialogRepo by remember { mutableStateOf(null) } - var releases by remember { mutableStateOf>(emptyList()) } - var isLoadingReleases by remember { mutableStateOf(false) } - - val pleaseEnterUrl = stringResource(Res.string.plugin_error_enter_url) - val urlSchemeError = stringResource(Res.string.plugin_error_url_scheme) - val downloadFailed = stringResource(Res.string.plugin_error_download_failed) - - val launcher = rememberFilePickerLauncher( - type = FileKitType.File( - extensions = if (platform.type == PlatformType.Android) listOf() else listOf("smplug") - ) - ) { file -> - if (file != null) { - scope.launch { pluginManager.preparePlugin(file.readBytes()) } - } - } - - fun submitUrl() { - val url = urlInput.trim() - if (url.isBlank()) { - urlError = pleaseEnterUrl - return - } - if (!url.startsWith("http://") && !url.startsWith("https://")) { - urlError = urlSchemeError - return - } - urlError = null - isLoadingUrl = true - scope.launch { - try { - pluginManager.addPluginFromURL(url) - urlInput = "" - } catch (e: Exception) { - urlError = e.message ?: downloadFailed - } finally { - isLoadingUrl = false - } - } - } - - pendingPlugin?.let { pending -> - val logoPath = remember(pending.entry.id) { - val path = pluginManager.pluginsDirPath / pending.entry.id.toPath() / "logo.png".toPath() - if (FileSystem.SYSTEM.exists(path)) path else null - } - PluginPermissionDialog( - pluginInfo = pending.entry, - title = pending.title, - message = pending.message, - confirmLabel = pending.confirmLabel, - existingPlugin = pending.existingEntry, - logoPath = logoPath, - onConfirm = if (pending.kind != PluginManager.InstallPromptKind.INFO && pending.confirmLabel != null) { - { pluginManager.confirmInstall() } - } else { - null - }, - onDismiss = { pluginManager.dismissInstall() } - ) - } - - showPluginInfo?.let { plugin -> - val logoPath = remember(plugin.id) { - val path = pluginManager.pluginsDirPath / plugin.id.toPath() / "logo.png".toPath() - if (FileSystem.SYSTEM.exists(path)) path else null - } - ThemedDialog( - onDismissRequest = { showPluginInfo = null }, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) - ) { - Surface( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(12.dp)), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) - ) { - if (logoPath != null) { - val platformContext = LocalPlatformContext.current - AsyncImage( - model = ImageRequest.Builder(platformContext) - .data(logoPath.toString()) - .crossfade(true) - .build(), - contentDescription = plugin.name, - modifier = Modifier.fillMaxSize() - ) - } else { - Box(contentAlignment = Alignment.Center) { - Icon( - Iconsax.IconsaxBox, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - } - } - Text( - plugin.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - } - }, - actions = { - PrimaryButton(onClick = { showPluginInfo = null }) { - Text("Close") - } - } - ) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() - ) { - if (plugin.description.isNotBlank()) { - Text( - plugin.description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - - DetailRow("Version", plugin.version) - DetailRow("API Version", plugin.apiVersion) - DetailRow("Author", plugin.author) - if (plugin.license.isNotBlank()) { - DetailRow("License", plugin.license) - } - - if (plugin.capabilities.isNotEmpty()) { - DetailChipsRow("Capabilities", plugin.capabilities.map { it.name }) - } - - if (plugin.abilities.isNotEmpty()) { - DetailChipsRow("Abilities", plugin.abilities.map { ability -> - when (ability) { - PluginAbility.METADATA -> "Metadata" - PluginAbility.AUDIO -> "Audio" - PluginAbility.LYRICS -> "Lyrics" - PluginAbility.SCROBBLE -> "Scrobble" - } - }) - } - - if (plugin.repository.isNotBlank()) { - ClickableDetailRow(label = "Repository", value = plugin.repository) { - openUrlInBrowser(plugin.repository) - } - } - - if (plugin.contact.isNotBlank()) { - DetailRow("Contact", plugin.contact) - } - - if (plugin.bugs.isNotBlank()) { - ClickableDetailRow(label = "Report Bugs", value = plugin.bugs) { - openUrlInBrowser(plugin.bugs) - } - } - } - } - } - - showPluginSupport?.let { plugin -> - ThemedDialog( - onDismissRequest = { showPluginSupport = null; supportText = null }, - title = { - Text( - "Support ${plugin.name}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - }, - actions = { - PrimaryButton(onClick = { showPluginSupport = null; supportText = null }) { - Text("Close") - } - } - ) { - if (isLoadingSupport) { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } else { - Text( - supportText ?: "", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - } - } - - installDialogRepo?.let { repo -> - PluginInstallDialog( - repo = repo, - releases = releases, - isLoadingReleases = isLoadingReleases, - onDismiss = { installDialogRepo = null }, - onInstall = { release -> - installDialogRepo = null - val smplugUrl = release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl - if (smplugUrl != null) { - discoverViewModel.installPluginFromUrl(smplugUrl, repo.id) - } - } - ) - } - - if (showInstallSheet) { - AdaptiveDialogBottomSheet( - onDismiss = { showInstallSheet = false }, - title = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - Iconsax.IconsaxImportArrow2Bulk, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - stringResource(Res.string.plugin_install_section_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) - } - }, - ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - stringResource(Res.string.plugin_section_url_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - TextField( - value = urlInput, - onValueChange = { urlInput = it; urlError = null }, - modifier = Modifier.weight(1f), - placeholder = { - Text( - stringResource(Res.string.plugin_url_placeholder), - style = MaterialTheme.typography.bodySmall - ) - }, - leadingIcon = { - Icon( - Iconsax.IconsaxLink, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - }, - isError = urlError != null, - singleLine = true, - ) - SecondaryIconButton( - onClick = { submitUrl() }, - enabled = !isLoadingUrl, - ) { - if (isLoadingUrl) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - } else { - Icon( - Iconsax.IconsaxImportArrow2Bulk, - contentDescription = stringResource(Res.string.plugin_action_download), - ) - } - } - } - - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - - Text( - stringResource(Res.string.plugin_section_file_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - OutlineButton( - onClick = { launcher.launch() }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - Iconsax.IconsaxExportArrowBulk, - contentDescription = stringResource(Res.string.plugin_action_install_from_file) - ) - Spacer(Modifier.width(8.dp)) - Text(stringResource(Res.string.plugin_action_install_from_file)) - } - } - } - } - - Scaffold( - topBar = { - ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) - } - ) { innerPadding -> - when (val state = pluginsState) { - is PluginManagerStates.Loading -> { - Box( - modifier = Modifier.fillMaxSize().padding(innerPadding), - contentAlignment = Alignment.Center - ) { CircularProgressIndicator() } - } - - is PluginManagerStates.Data -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) { - val discoverListState = rememberLazyListState() - LazyColumn( - state = discoverListState, - modifier = Modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), - contentPadding = PaddingValues( - start = 12.dp, - end = 12.dp, - top = 8.dp, - bottom = 24.dp + shellBottomInset - ), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) - { - // ── Configure header ────────────────────────────── - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - stringResource(Res.string.plugin_configure_title), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold - ) - PrimaryButton(onClick = { showInstallSheet = true }) { - Icon( - Iconsax.IconsaxAdd, - contentDescription = "Install a plugin", - ) - Text(stringResource(Res.string.plugin_install_section_title)) - } - } - } - - // ── Default ability plugin selectors ───────────────── - item { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp, bottom = 4.dp) - ) { - PluginAbility.entries.forEachIndexed { index, ability -> - if (index > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy( - alpha = 0.5f - ), - ) - } - val selectedPlugin = state.selectedPlugins[ability] - DefaultAbilityPluginSelector( - ability = ability, - selectedPlugin = selectedPlugin, - state = when (ability) { - PluginAbility.METADATA -> pluginManager.metadataPlugins - PluginAbility.AUDIO -> pluginManager.audioPlugins - PluginAbility.LYRICS -> pluginManager.lyricsPlugins - PluginAbility.SCROBBLE -> pluginManager.scrobblePlugins - }, - onSelected = { plugin -> - pluginManager.setSelectedPlugin(ability, plugin) - }, - ) - } - } - } - } - - if (state.plugins.isEmpty()) { - item { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Surface( - modifier = Modifier.size(72.dp) - .clip(RoundedCornerShape(18.dp)), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - Iconsax.IconsaxBox, - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - } - Text( - stringResource(Res.string.plugin_empty_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) - Text( - stringResource(Res.string.plugin_empty_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } else { - // ── Plugin list ─────────────────────────────────── - item { - val noun = if (state.plugins.size == 1) { - stringResource(Res.string.plugin_installed_singular) - } else { - stringResource(Res.string.plugin_installed_plural) - } - Text( - stringResource( - Res.string.plugin_installed_count, - state.plugins.size, - noun - ), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) - ) - } - item { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) { - Column( - modifier = Modifier.fillMaxWidth() - ) { - state.plugins.forEachIndexed { index, plugin -> - if (index > 0) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy( - alpha = 0.5f - ), - ) - } - val isSelected = - state.selectedPlugins.containsValue(plugin) - val selectedAbility = state.selectedPlugins - .entries - .firstOrNull { (_, selectedPlugin) -> selectedPlugin.id == plugin.id } - ?.key - val selectedService = selectedAbility?.let { ability -> - activeServices?.get(ability) - } - - var requiresAuth by remember( - plugin.id, - selectedService - ) { - mutableStateOf(false) - } - var isLoggedIn by remember(plugin.id, selectedService) { - mutableStateOf(false) - } - - LaunchedEffect(plugin.id, selectedService) { - requiresAuth = false - isLoggedIn = false - val service = - selectedService ?: return@LaunchedEffect - - - service.use { - val pluginRequiresAuth = - coreAPI.requiresAuthentication - requiresAuth = pluginRequiresAuth - if (!pluginRequiresAuth) return@use - - coreAPI.loggedInFlow.collect { loggedIn -> - isLoggedIn = loggedIn - } - } - } - - val logoPath = remember(plugin.id) { - val path = - pluginManager.pluginsDirPath / plugin.id.toPath() / "logo.png".toPath() - if (FileSystem.SYSTEM.exists(path)) path else null - } - - PluginCard( - plugin = plugin, - isSelected = isSelected, - onRemove = { - scope.launch { pluginManager.removePlugin(plugin) } - }, - isLoggedIn = isLoggedIn, - logoPath = logoPath, - onInfo = { showPluginInfo = plugin }, - onSupport = if (selectedService != null) { - { - isLoadingSupport = true - scope.launch { - showPluginSupport = plugin - val version = SemVer.parse(plugin.version) - selectedService.use { - supportText = - coreAPI.supportMarkdownText(version) - } - isLoadingSupport = false - } - } - } else { - null - }, - onLogin = if (requiresAuth && selectedService != null) { - { - pluginManager.launchTask { - selectedService.use { coreAPI.login() } - } - } - } else { - null - }, - onLogout = if (requiresAuth && selectedService != null) { - { - pluginManager.launchTask { - selectedService.use { coreAPI.logout() } - } - // should clear webview data after logout - scope.launch { webviewController.clearData() } - } - } else { - null - } - ) - } - } - } - } - } - - // ── Discover plugins ───────────────────────── - if (discoverState.isLoading || discoverState.repos.isNotEmpty()) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - Iconsax.IconsaxGlobe, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - "Discover Plugins", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold - ) - } - } - items( - discoverState.repos, - key = { it.id } - ) { repo -> - val isOfficial = repo.owner.login in OFFICIAL_PLUGIN_OWNERS - val isVerified = repo.owner.login in VERIFIED_PLUGIN_OWNERS - val isInstalling = discoverState.installingRepoId == repo.id - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - val platformContext = LocalPlatformContext.current - AsyncImage( - model = ImageRequest.Builder(platformContext) - .data(repo.owner.avatarUrl) - .crossfade(true) - .build(), - contentDescription = repo.owner.login, - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(8.dp)) - ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text( - repo.fullName.split("/") - .last() - .replace("spotube-plugin-", "") - .kebabToTitleCase(), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false) - ) - if (isOfficial) { - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.primary.copy( - alpha = 0.15f - ) - ) { - Text( - "Official", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding( - horizontal = 5.dp, - vertical = 1.dp - ) - ) - } - } else if (isVerified) { - Surface( - shape = RoundedCornerShape(4.dp), - color = Color(0xFF4CAF50).copy(alpha = 0.15f) - ) { - Row( - modifier = Modifier.padding( - horizontal = 5.dp, - vertical = 1.dp - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy( - 2.dp - ) - ) { - Icon( - Iconsax.IconsaxCheckCircle, - contentDescription = null, - modifier = Modifier.size(10.dp), - tint = Color(0xFF4CAF50) - ) - Text( - "Verified", - style = MaterialTheme.typography.labelSmall, - color = Color(0xFF4CAF50) - ) - } - } - } - } - if (!repo.description.isNullOrBlank()) { - Text( - repo.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - repo.owner.login, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(3.dp) - ) { - Icon( - Iconsax.IconsaxHeart, - contentDescription = "Github Stars", - modifier = Modifier.size(11.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - repo.stargazersCount.toString(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.clickable { - openUrlInBrowser(repo.htmlUrl) - } - ) { - Row( - modifier = Modifier.padding( - horizontal = 5.dp, - vertical = 2.dp - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy( - 3.dp - ) - ) { - Icon( - Iconsax.CarbonGithubLogo, - contentDescription = "Github Repository URL", - modifier = Modifier.size(10.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - "github.com", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - SecondaryButton( - onClick = { - installDialogRepo = repo - isLoadingReleases = true - releases = emptyList() - scope.launch { - val parts = repo.fullName.split("/") - releases = discoverViewModel.getReleases(parts[0], parts[1]) - isLoadingReleases = false - } - }, - enabled = !isInstalling - ) { - if (isInstalling) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp - ) - } else { - Icon( - Iconsax.IconsaxAdd, - contentDescription = null, - ) - } - Text(stringResource(Res.string.plugin_section_install)) - } - } - } - } - - if (discoverState.isLoadingMore) { - item { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - } - } - - if (discoverState.error != null) { - item { - Text( - discoverState.error ?: "", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding( - horizontal = 4.dp, - vertical = 8.dp - ) - ) - } - } - } - } - - val density = LocalDensity.current - val shouldLoadMore = remember(density) { - derivedStateOf { - val totalItems = discoverListState.layoutInfo.totalItemsCount - val lastVisibleIndex = - discoverListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index - ?: 0 - totalItems > 0 && lastVisibleIndex >= totalItems - 3 - } - } - - LaunchedEffect(shouldLoadMore.value) { - if (shouldLoadMore.value) { - discoverViewModel.loadNextPage() - } - } - } - } - } - } -} - -@Composable -private fun DetailRow(label: String, value: String) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - "$label:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.widthIn(min = 90.dp) - ) - Text( - value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } -} - -@Composable -private fun ClickableDetailRow(label: String, value: String, onClick: () -> Unit) { - Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - "$label:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.widthIn(min = 90.dp) - ) - Text( - value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } -} - -@Composable -private fun DetailChipsRow(label: String, chips: List) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - "$label:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.widthIn(min = 90.dp) - ) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - chips.forEach { chip -> - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f) - ) { - Text( - chip, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - } - } -} - -@Composable -fun DefaultAbilityPluginSelector( - ability: PluginAbility, - state: StateFlow>, - selectedPlugin: PluginEntry? = null, - onSelected: (PluginEntry?) -> Unit = { }, -) { - val plugins by state.collectAsStateWithLifecycle() - val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins) - - val menuItems = buildList { - if (plugins.isNotEmpty()) { - plugins.forEach { plugin -> - val isSelected = selectedPlugin?.name == plugin.name - add( - AdaptiveMenuItem( - label = plugin.name, - onClick = { onSelected(plugin) }, - selected = isSelected, - ) - ) - } - } else { - add( - AdaptiveMenuItem( - label = noPluginsText, - onClick = { }, - enabled = false, - ) - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Surface( - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = when (ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) - PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) - PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) - } - ) { - Icon( - imageVector = when (ability) { - PluginAbility.METADATA -> Iconsax.IconsaxDocumentText - PluginAbility.AUDIO -> Iconsax.IconsaxMusic - PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft - PluginAbility.SCROBBLE -> Iconsax.IconsaxSound - }, - contentDescription = stringResource( - Res.string.settings_plugins_plugin_content_description, - ability.displayLabel() - ), - modifier = Modifier.padding(8.dp), - tint = when (ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50) - PluginAbility.AUDIO -> Color(0xFF2196F3) - PluginAbility.LYRICS -> Color(0xFFFFC107) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0) - } - ) - } - - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource( - Res.string.settings_plugins_default_ability_title, - ability.displayLabel() - ), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface - ) - if (selectedPlugin != null) { - Text( - selectedPlugin.name, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 4.dp) - ) - } else { - Text( - stringResource(Res.string.settings_plugins_no_selection), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp) - ) - } - } - } - - AdaptiveDropdownBottomSheet( - items = menuItems, - headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, - header = { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Surface( - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - color = when (ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) - PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) - PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) - } - ) { - Icon( - imageVector = when (ability) { - PluginAbility.METADATA -> Iconsax.IconsaxDocumentText - PluginAbility.AUDIO -> Iconsax.IconsaxMusic - PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft - PluginAbility.SCROBBLE -> Iconsax.IconsaxSound - }, - contentDescription = null, - modifier = Modifier.padding(8.dp), - tint = when (ability) { - PluginAbility.METADATA -> Color(0xFF4CAF50) - PluginAbility.AUDIO -> Color(0xFF2196F3) - PluginAbility.LYRICS -> Color(0xFFFFC107) - PluginAbility.SCROBBLE -> Color(0xFF9C27B0) - } - ) - } - Column(modifier = Modifier.weight(1f)) { - Text( - stringResource( - Res.string.settings_plugins_default_ability_title, - ability.displayLabel() - ), - style = MaterialTheme.typography.titleMedium, - ) - selectedPlugin?.let { - Text( - it.name, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - } - } - }, - trigger = { onClick -> - OutlineButton(onClick = onClick) { - Text( - if (selectedPlugin != null) { - stringResource(Res.string.settings_plugins_action_change) - } else { - stringResource(Res.string.settings_plugins_action_select) + " " - }, - ) - Icon( - imageVector = if (selectedPlugin != null) Iconsax.IconsaxEdit else Iconsax.IconsaxArrowDown4, - contentDescription = null, - modifier = Modifier.size(14.dp), - ) - } - }, - ) - } -} - -@Composable -private fun PluginAbility.displayLabel(): String { - return when (this) { - PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata) - PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio) - PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics) - PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble) - } -} - +/* + * 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 . + */ + +package dev.krtirtho.spotube.modules.plugin + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +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.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.crossfade +import dev.krtirtho.spotube.PlatformType +import dev.krtirtho.spotube.core.extras.kebabToTitleCase +import dev.krtirtho.spotube.core.ui.base.Card +import dev.krtirtho.spotube.core.ui.base.OutlineButton +import dev.krtirtho.spotube.core.ui.base.PrimaryButton +import dev.krtirtho.spotube.core.ui.base.SecondaryButton +import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton +import dev.krtirtho.spotube.core.ui.base.TextField +import dev.krtirtho.spotube.core.ui.base.ThemedDialog +import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet +import dev.krtirtho.spotube.core.ui.component.AdaptiveDropdownBottomSheet +import dev.krtirtho.spotube.core.ui.component.AdaptiveMenuItem +import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar +import dev.krtirtho.spotube.core.ui.component.HeaderDisplayMode +import dev.krtirtho.spotube.core.webview.WebViewController +import dev.krtirtho.spotube.getPlatform +import dev.krtirtho.spotube.modules.plugin.components.PluginCard +import dev.krtirtho.spotube.modules.plugin.components.PluginInstallDialog +import dev.krtirtho.spotube.modules.plugin.components.PluginPermissionDialog +import dev.krtirtho.spotube.modules.shell.LocalAppShellBottomInset +import dev.krtirtho.spotube.openUrlInBrowser +import dev.krtirtho.spotube.resources.iconsax.CarbonGithubLogo +import dev.krtirtho.spotube.resources.iconsax.Iconsax +import dev.krtirtho.spotube.resources.iconsax.IconsaxAdd +import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowDown4 +import dev.krtirtho.spotube.resources.iconsax.IconsaxBox +import dev.krtirtho.spotube.resources.iconsax.IconsaxCheckCircle +import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText +import dev.krtirtho.spotube.resources.iconsax.IconsaxEdit +import dev.krtirtho.spotube.resources.iconsax.IconsaxExportArrowBulk +import dev.krtirtho.spotube.resources.iconsax.IconsaxGlobe +import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart +import dev.krtirtho.spotube.resources.iconsax.IconsaxImportArrow2Bulk +import dev.krtirtho.spotube.resources.iconsax.IconsaxLink +import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic +import dev.krtirtho.spotube.resources.iconsax.IconsaxSound +import dev.krtirtho.spotube.resources.iconsax.IconsaxTextalignLeft +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher +import io.github.vinceglb.filekit.readBytes +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import net.swiftzer.semver.SemVer +import okio.FileSystem +import okio.Path.Companion.toPath +import okio.SYSTEM +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.plugin_action_download +import spotube.composeapp.generated.resources.plugin_action_install_from_file +import spotube.composeapp.generated.resources.plugin_configure_title +import spotube.composeapp.generated.resources.plugin_empty_subtitle +import spotube.composeapp.generated.resources.plugin_empty_title +import spotube.composeapp.generated.resources.plugin_error_download_failed +import spotube.composeapp.generated.resources.plugin_error_enter_url +import spotube.composeapp.generated.resources.plugin_error_url_scheme +import spotube.composeapp.generated.resources.plugin_install_section_title +import spotube.composeapp.generated.resources.plugin_installed_count +import spotube.composeapp.generated.resources.plugin_installed_plural +import spotube.composeapp.generated.resources.plugin_installed_singular +import spotube.composeapp.generated.resources.plugin_screen_title +import spotube.composeapp.generated.resources.plugin_section_file_title +import spotube.composeapp.generated.resources.plugin_section_install +import spotube.composeapp.generated.resources.plugin_section_url_title +import spotube.composeapp.generated.resources.plugin_url_placeholder +import spotube.composeapp.generated.resources.settings_plugins_ability_audio +import spotube.composeapp.generated.resources.settings_plugins_ability_lyrics +import spotube.composeapp.generated.resources.settings_plugins_ability_metadata +import spotube.composeapp.generated.resources.settings_plugins_ability_scrobble +import spotube.composeapp.generated.resources.settings_plugins_action_change +import spotube.composeapp.generated.resources.settings_plugins_action_select +import spotube.composeapp.generated.resources.settings_plugins_default_ability_title +import spotube.composeapp.generated.resources.settings_plugins_no_plugins +import spotube.composeapp.generated.resources.settings_plugins_no_selection +import spotube.composeapp.generated.resources.settings_plugins_plugin_content_description + +private val OFFICIAL_PLUGIN_OWNERS = setOf("KRTirtho", "team-spotube") + +private val VERIFIED_PLUGIN_OWNERS = setOf() + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PluginScreen( + pluginManager: PluginManager, + webviewController: WebViewController = koinInject() +) { + val scope = rememberCoroutineScope() + val platform = remember { getPlatform() } + val pendingPlugin by pluginManager.pendingPlugin.collectAsStateWithLifecycle() + val pluginsState by pluginManager.state.collectAsStateWithLifecycle() + val activeServices by pluginManager.ziplineServices.collectAsStateWithLifecycle() + val shellBottomInset = LocalAppShellBottomInset.current + + var urlInput by remember { mutableStateOf("") } + var urlError by remember { mutableStateOf(null) } + var isLoadingUrl by remember { mutableStateOf(false) } + var showInstallSheet by remember { mutableStateOf(false) } + + val discoverViewModel: PluginDiscoverViewModel = koinViewModel() + val discoverState by discoverViewModel.state.collectAsStateWithLifecycle() + + var showPluginInfo by remember { mutableStateOf(null) } + var showPluginSupport by remember { mutableStateOf(null) } + var supportText by remember { mutableStateOf(null) } + var isLoadingSupport by remember { mutableStateOf(false) } + + var installDialogRepo by remember { mutableStateOf(null) } + var releases by remember { mutableStateOf>(emptyList()) } + var isLoadingReleases by remember { mutableStateOf(false) } + + val pleaseEnterUrl = stringResource(Res.string.plugin_error_enter_url) + val urlSchemeError = stringResource(Res.string.plugin_error_url_scheme) + val downloadFailed = stringResource(Res.string.plugin_error_download_failed) + + val launcher = rememberFilePickerLauncher( + type = FileKitType.File( + extensions = if (platform.type == PlatformType.Android) listOf() else listOf("smplug") + ) + ) { file -> + if (file != null) { + scope.launch { pluginManager.preparePlugin(file.readBytes()) } + } + } + + fun submitUrl() { + val url = urlInput.trim() + if (url.isBlank()) { + urlError = pleaseEnterUrl + return + } + if (!url.startsWith("http://") && !url.startsWith("https://")) { + urlError = urlSchemeError + return + } + urlError = null + isLoadingUrl = true + scope.launch { + try { + pluginManager.addPluginFromURL(url) + urlInput = "" + } catch (e: Exception) { + urlError = e.message ?: downloadFailed + } finally { + isLoadingUrl = false + } + } + } + + pendingPlugin?.let { pending -> + val logoPath = remember(pending.entry.id) { + val path = pluginManager.pluginsDirPath / pending.entry.id.toPath() / "logo.png".toPath() + if (FileSystem.SYSTEM.exists(path)) path else null + } + PluginPermissionDialog( + pluginInfo = pending.entry, + title = pending.title, + message = pending.message, + confirmLabel = pending.confirmLabel, + existingPlugin = pending.existingEntry, + logoPath = logoPath, + onConfirm = if (pending.kind != PluginManager.InstallPromptKind.INFO && pending.confirmLabel != null) { + { pluginManager.confirmInstall() } + } else { + null + }, + onDismiss = { pluginManager.dismissInstall() } + ) + } + + showPluginInfo?.let { plugin -> + val logoPath = remember(plugin.id) { + val path = pluginManager.pluginsDirPath / plugin.id.toPath() / "logo.png".toPath() + if (FileSystem.SYSTEM.exists(path)) path else null + } + ThemedDialog( + onDismissRequest = { showPluginInfo = null }, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Surface( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + ) { + if (logoPath != null) { + val platformContext = LocalPlatformContext.current + AsyncImage( + model = ImageRequest.Builder(platformContext) + .data(logoPath.toString()) + .crossfade(true) + .build(), + contentDescription = plugin.name, + modifier = Modifier.fillMaxSize() + ) + } else { + Box(contentAlignment = Alignment.Center) { + Icon( + Iconsax.IconsaxBox, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + Text( + plugin.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + }, + actions = { + PrimaryButton(onClick = { showPluginInfo = null }) { + Text("Close") + } + } + ) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth() + ) { + if (plugin.description.isNotBlank()) { + Text( + plugin.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + + DetailRow("Version", plugin.version) + DetailRow("API Version", plugin.apiVersion) + DetailRow("Author", plugin.author) + if (plugin.license.isNotBlank()) { + DetailRow("License", plugin.license) + } + + if (plugin.capabilities.isNotEmpty()) { + DetailChipsRow("Capabilities", plugin.capabilities.map { it.name }) + } + + if (plugin.abilities.isNotEmpty()) { + DetailChipsRow("Abilities", plugin.abilities.map { ability -> + when (ability) { + PluginAbility.METADATA -> "Metadata" + PluginAbility.AUDIO -> "Audio" + PluginAbility.LYRICS -> "Lyrics" + PluginAbility.SCROBBLE -> "Scrobble" + } + }) + } + + if (plugin.repository.isNotBlank()) { + ClickableDetailRow(label = "Repository", value = plugin.repository) { + openUrlInBrowser(plugin.repository) + } + } + + if (plugin.contact.isNotBlank()) { + DetailRow("Contact", plugin.contact) + } + + if (plugin.bugs.isNotBlank()) { + ClickableDetailRow(label = "Report Bugs", value = plugin.bugs) { + openUrlInBrowser(plugin.bugs) + } + } + } + } + } + + showPluginSupport?.let { plugin -> + ThemedDialog( + onDismissRequest = { showPluginSupport = null; supportText = null }, + title = { + Text( + "Support ${plugin.name}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + }, + actions = { + PrimaryButton(onClick = { showPluginSupport = null; supportText = null }) { + Text("Close") + } + } + ) { + if (isLoadingSupport) { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + Text( + supportText ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + + installDialogRepo?.let { repo -> + PluginInstallDialog( + repo = repo, + releases = releases, + isLoadingReleases = isLoadingReleases, + onDismiss = { installDialogRepo = null }, + onInstall = { release -> + installDialogRepo = null + val smplugUrl = release.assets.firstOrNull { it.name.endsWith(".smplug") }?.browserDownloadUrl + if (smplugUrl != null) { + discoverViewModel.installPluginFromUrl(smplugUrl, repo.id) + } + } + ) + } + + if (showInstallSheet) { + AdaptiveDialogBottomSheet( + onDismiss = { showInstallSheet = false }, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Iconsax.IconsaxImportArrow2Bulk, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + stringResource(Res.string.plugin_install_section_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + } + }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(Res.string.plugin_section_url_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextField( + value = urlInput, + onValueChange = { urlInput = it; urlError = null }, + modifier = Modifier.weight(1f), + placeholder = { + Text( + stringResource(Res.string.plugin_url_placeholder), + style = MaterialTheme.typography.bodySmall + ) + }, + leadingIcon = { + Icon( + Iconsax.IconsaxLink, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + }, + isError = urlError != null, + singleLine = true, + ) + SecondaryIconButton( + onClick = { submitUrl() }, + enabled = !isLoadingUrl, + ) { + if (isLoadingUrl) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Icon( + Iconsax.IconsaxImportArrow2Bulk, + contentDescription = stringResource(Res.string.plugin_action_download), + ) + } + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + + Text( + stringResource(Res.string.plugin_section_file_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + OutlineButton( + onClick = { launcher.launch() }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + Iconsax.IconsaxExportArrowBulk, + contentDescription = stringResource(Res.string.plugin_action_install_from_file) + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(Res.string.plugin_action_install_from_file)) + } + } + } + } + + Scaffold( + topBar = { + ApplicationMainBar(title = { Text(stringResource(Res.string.plugin_screen_title)) }) + } + ) { innerPadding -> + when (val state = pluginsState) { + is PluginManagerStates.Loading -> { + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center + ) { CircularProgressIndicator() } + } + + is PluginManagerStates.Data -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + val discoverListState = rememberLazyListState() + LazyColumn( + state = discoverListState, + modifier = Modifier.widthIn(max = 1280.dp).align(Alignment.TopCenter), + contentPadding = PaddingValues( + start = 12.dp, + end = 12.dp, + top = 8.dp, + bottom = 24.dp + shellBottomInset + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) + { + // ── Configure header ────────────────────────────── + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(Res.string.plugin_configure_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + PrimaryButton(onClick = { showInstallSheet = true }) { + Icon( + Iconsax.IconsaxAdd, + contentDescription = "Install a plugin", + ) + Text(stringResource(Res.string.plugin_install_section_title)) + } + } + } + + // ── Default ability plugin selectors ───────────────── + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 4.dp) + ) { + PluginAbility.entries.forEachIndexed { index, ability -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy( + alpha = 0.5f + ), + ) + } + val selectedPlugin = state.selectedPlugins[ability] + DefaultAbilityPluginSelector( + ability = ability, + selectedPlugin = selectedPlugin, + state = when (ability) { + PluginAbility.METADATA -> pluginManager.metadataPlugins + PluginAbility.AUDIO -> pluginManager.audioPlugins + PluginAbility.LYRICS -> pluginManager.lyricsPlugins + PluginAbility.SCROBBLE -> pluginManager.scrobblePlugins + }, + onSelected = { plugin -> + pluginManager.setSelectedPlugin(ability, plugin) + }, + ) + } + } + } + } + + if (state.plugins.isEmpty()) { + item { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + modifier = Modifier.size(72.dp) + .clip(RoundedCornerShape(18.dp)), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + Iconsax.IconsaxBox, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + Text( + stringResource(Res.string.plugin_empty_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + stringResource(Res.string.plugin_empty_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } else { + // ── Plugin list ─────────────────────────────────── + item { + val noun = if (state.plugins.size == 1) { + stringResource(Res.string.plugin_installed_singular) + } else { + stringResource(Res.string.plugin_installed_plural) + } + Text( + stringResource( + Res.string.plugin_installed_count, + state.plugins.size, + noun + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) + ) + } + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + state.plugins.forEachIndexed { index, plugin -> + if (index > 0) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy( + alpha = 0.5f + ), + ) + } + val isSelected = + state.selectedPlugins.containsValue(plugin) + val selectedAbility = state.selectedPlugins + .entries + .firstOrNull { (_, selectedPlugin) -> selectedPlugin.id == plugin.id } + ?.key + val selectedService = selectedAbility?.let { ability -> + activeServices?.get(ability) + } + + var requiresAuth by remember( + plugin.id, + selectedService + ) { + mutableStateOf(false) + } + var isLoggedIn by remember(plugin.id, selectedService) { + mutableStateOf(false) + } + + LaunchedEffect(plugin.id, selectedService) { + requiresAuth = false + isLoggedIn = false + val service = + selectedService ?: return@LaunchedEffect + + + service.use { + val pluginRequiresAuth = + coreAPI.requiresAuthentication + requiresAuth = pluginRequiresAuth + if (!pluginRequiresAuth) return@use + + coreAPI.loggedInFlow.collect { loggedIn -> + isLoggedIn = loggedIn + } + } + } + + val logoPath = remember(plugin.id) { + val path = + pluginManager.pluginsDirPath / plugin.id.toPath() / "logo.png".toPath() + if (FileSystem.SYSTEM.exists(path)) path else null + } + + PluginCard( + plugin = plugin, + isSelected = isSelected, + onRemove = { + scope.launch { pluginManager.removePlugin(plugin) } + }, + isLoggedIn = isLoggedIn, + logoPath = logoPath, + onInfo = { showPluginInfo = plugin }, + onSupport = if (selectedService != null) { + { + isLoadingSupport = true + scope.launch { + showPluginSupport = plugin + val version = SemVer.parse(plugin.version) + selectedService.use { + supportText = + coreAPI.supportMarkdownText(version) + } + isLoadingSupport = false + } + } + } else { + null + }, + onLogin = if (requiresAuth && selectedService != null) { + { + pluginManager.launchTask { + selectedService.use { coreAPI.login() } + } + } + } else { + null + }, + onLogout = if (requiresAuth && selectedService != null) { + { + pluginManager.launchTask { + selectedService.use { coreAPI.logout() } + } + // should clear webview data after logout + scope.launch { webviewController.clearData(plugin.id) } + } + } else { + null + } + ) + } + } + } + } + } + + // ── Discover plugins ───────────────────────── + if (discoverState.isLoading || discoverState.repos.isNotEmpty()) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Iconsax.IconsaxGlobe, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + "Discover Plugins", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + } + } + items( + discoverState.repos, + key = { it.id } + ) { repo -> + val isOfficial = repo.owner.login in OFFICIAL_PLUGIN_OWNERS + val isVerified = repo.owner.login in VERIFIED_PLUGIN_OWNERS + val isInstalling = discoverState.installingRepoId == repo.id + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + val platformContext = LocalPlatformContext.current + AsyncImage( + model = ImageRequest.Builder(platformContext) + .data(repo.owner.avatarUrl) + .crossfade(true) + .build(), + contentDescription = repo.owner.login, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(8.dp)) + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + repo.fullName.split("/") + .last() + .replace("spotube-plugin-", "") + .kebabToTitleCase(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + if (isOfficial) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.primary.copy( + alpha = 0.15f + ) + ) { + Text( + "Official", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding( + horizontal = 5.dp, + vertical = 1.dp + ) + ) + } + } else if (isVerified) { + Surface( + shape = RoundedCornerShape(4.dp), + color = Color(0xFF4CAF50).copy(alpha = 0.15f) + ) { + Row( + modifier = Modifier.padding( + horizontal = 5.dp, + vertical = 1.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + 2.dp + ) + ) { + Icon( + Iconsax.IconsaxCheckCircle, + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = Color(0xFF4CAF50) + ) + Text( + "Verified", + style = MaterialTheme.typography.labelSmall, + color = Color(0xFF4CAF50) + ) + } + } + } + } + if (!repo.description.isNullOrBlank()) { + Text( + repo.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + repo.owner.login, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp) + ) { + Icon( + Iconsax.IconsaxHeart, + contentDescription = "Github Stars", + modifier = Modifier.size(11.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + repo.stargazersCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.clickable { + openUrlInBrowser(repo.htmlUrl) + } + ) { + Row( + modifier = Modifier.padding( + horizontal = 5.dp, + vertical = 2.dp + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + 3.dp + ) + ) { + Icon( + Iconsax.CarbonGithubLogo, + contentDescription = "Github Repository URL", + modifier = Modifier.size(10.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "github.com", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + SecondaryButton( + onClick = { + installDialogRepo = repo + isLoadingReleases = true + releases = emptyList() + scope.launch { + val parts = repo.fullName.split("/") + releases = discoverViewModel.getReleases(parts[0], parts[1]) + isLoadingReleases = false + } + }, + enabled = !isInstalling + ) { + if (isInstalling) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + Iconsax.IconsaxAdd, + contentDescription = null, + ) + } + Text(stringResource(Res.string.plugin_section_install)) + } + } + } + } + + if (discoverState.isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + + if (discoverState.error != null) { + item { + Text( + discoverState.error ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding( + horizontal = 4.dp, + vertical = 8.dp + ) + ) + } + } + } + } + + val density = LocalDensity.current + val shouldLoadMore = remember(density) { + derivedStateOf { + val totalItems = discoverListState.layoutInfo.totalItemsCount + val lastVisibleIndex = + discoverListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index + ?: 0 + totalItems > 0 && lastVisibleIndex >= totalItems - 3 + } + } + + LaunchedEffect(shouldLoadMore.value) { + if (shouldLoadMore.value) { + discoverViewModel.loadNextPage() + } + } + } + } + } + } +} + +@Composable +private fun DetailRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "$label:", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.widthIn(min = 90.dp) + ) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +@Composable +private fun ClickableDetailRow(label: String, value: String, onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "$label:", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.widthIn(min = 90.dp) + ) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun DetailChipsRow(label: String, chips: List) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "$label:", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.widthIn(min = 90.dp) + ) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + chips.forEach { chip -> + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f) + ) { + Text( + chip, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + } +} + +@Composable +fun DefaultAbilityPluginSelector( + ability: PluginAbility, + state: StateFlow>, + selectedPlugin: PluginEntry? = null, + onSelected: (PluginEntry?) -> Unit = { }, +) { + val plugins by state.collectAsStateWithLifecycle() + val noPluginsText = stringResource(Res.string.settings_plugins_no_plugins) + + val menuItems = buildList { + if (plugins.isNotEmpty()) { + plugins.forEach { plugin -> + val isSelected = selectedPlugin?.name == plugin.name + add( + AdaptiveMenuItem( + label = plugin.name, + onClick = { onSelected(plugin) }, + selected = isSelected, + ) + ) + } + } else { + add( + AdaptiveMenuItem( + label = noPluginsText, + onClick = { }, + enabled = false, + ) + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + modifier = Modifier.clip(RoundedCornerShape(8.dp)), + color = when (ability) { + PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) + PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) + PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) + PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) + } + ) { + Icon( + imageVector = when (ability) { + PluginAbility.METADATA -> Iconsax.IconsaxDocumentText + PluginAbility.AUDIO -> Iconsax.IconsaxMusic + PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft + PluginAbility.SCROBBLE -> Iconsax.IconsaxSound + }, + contentDescription = stringResource( + Res.string.settings_plugins_plugin_content_description, + ability.displayLabel() + ), + modifier = Modifier.padding(8.dp), + tint = when (ability) { + PluginAbility.METADATA -> Color(0xFF4CAF50) + PluginAbility.AUDIO -> Color(0xFF2196F3) + PluginAbility.LYRICS -> Color(0xFFFFC107) + PluginAbility.SCROBBLE -> Color(0xFF9C27B0) + } + ) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource( + Res.string.settings_plugins_default_ability_title, + ability.displayLabel() + ), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface + ) + if (selectedPlugin != null) { + Text( + selectedPlugin.name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp) + ) + } else { + Text( + stringResource(Res.string.settings_plugins_no_selection), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } + + AdaptiveDropdownBottomSheet( + items = menuItems, + headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, + header = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + modifier = Modifier.clip(RoundedCornerShape(8.dp)), + color = when (ability) { + PluginAbility.METADATA -> Color(0xFF4CAF50).copy(alpha = 0.1f) + PluginAbility.AUDIO -> Color(0xFF2196F3).copy(alpha = 0.1f) + PluginAbility.LYRICS -> Color(0xFFFFC107).copy(alpha = 0.1f) + PluginAbility.SCROBBLE -> Color(0xFF9C27B0).copy(alpha = 0.1f) + } + ) { + Icon( + imageVector = when (ability) { + PluginAbility.METADATA -> Iconsax.IconsaxDocumentText + PluginAbility.AUDIO -> Iconsax.IconsaxMusic + PluginAbility.LYRICS -> Iconsax.IconsaxTextalignLeft + PluginAbility.SCROBBLE -> Iconsax.IconsaxSound + }, + contentDescription = null, + modifier = Modifier.padding(8.dp), + tint = when (ability) { + PluginAbility.METADATA -> Color(0xFF4CAF50) + PluginAbility.AUDIO -> Color(0xFF2196F3) + PluginAbility.LYRICS -> Color(0xFFFFC107) + PluginAbility.SCROBBLE -> Color(0xFF9C27B0) + } + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource( + Res.string.settings_plugins_default_ability_title, + ability.displayLabel() + ), + style = MaterialTheme.typography.titleMedium, + ) + selectedPlugin?.let { + Text( + it.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + }, + trigger = { onClick -> + OutlineButton(onClick = onClick) { + Text( + if (selectedPlugin != null) { + stringResource(Res.string.settings_plugins_action_change) + } else { + stringResource(Res.string.settings_plugins_action_select) + " " + }, + ) + Icon( + imageVector = if (selectedPlugin != null) Iconsax.IconsaxEdit else Iconsax.IconsaxArrowDown4, + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + } + }, + ) + } +} + +@Composable +private fun PluginAbility.displayLabel(): String { + return when (this) { + PluginAbility.METADATA -> stringResource(Res.string.settings_plugins_ability_metadata) + PluginAbility.AUDIO -> stringResource(Res.string.settings_plugins_ability_audio) + PluginAbility.LYRICS -> stringResource(Res.string.settings_plugins_ability_lyrics) + PluginAbility.SCROBBLE -> stringResource(Res.string.settings_plugins_ability_scrobble) + } +} + diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt index 13ee072b..fbb391db 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.ios.kt @@ -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 . - */ - -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 . + */ + +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) } \ No newline at end of file diff --git a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt index eff8380e..2742885f 100644 --- a/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/dev/krtirtho/spotube/core/webview/PlatformWebviewConfig.jvm.kt @@ -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 . - */ - -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() - - 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 . + */ + +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() + + 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() + val dataDirStr = "${paths.getApplicationCacheDirPath()}/webview_data/$pluginId" + val dataDir = dataDirStr.toPath() + if (FileSystem.SYSTEM.exists(dataDir)) { + FileSystem.SYSTEM.deleteRecursively(dataDir) + } } \ No newline at end of file