Compare commits

..

No commits in common. "fe0ebb47fecf099c646f5c73af8c6ecaff3e661b" and "4bc487782f76d8812c9c1cc405b300851ac88a28" have entirely different histories.

10 changed files with 2189 additions and 2215 deletions

16
.github/dependabot.yml vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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