mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
Compare commits
2 Commits
4bc487782f
...
fe0ebb47fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe0ebb47fe | ||
|
|
14ee4f6a4c |
16
.github/dependabot.yml
vendored
16
.github/dependabot.yml
vendored
@ -1,16 +0,0 @@
|
||||
version: 2
|
||||
|
||||
enable-beta-ecosystems: true
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "pub"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
target-branch: "dev"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
target-branch: "dev"
|
||||
@ -1,24 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
|
||||
actual fun platformWebviewConfig(webView: WebViewState) {
|
||||
webView.webView?.nativeWebView?.settings?.domStorageEnabled = true
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebStorage
|
||||
import android.webkit.WebView
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
|
||||
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
|
||||
val nativeWebView = webView.webView?.nativeWebView as? WebView ?: return
|
||||
nativeWebView.settings.domStorageEnabled = true
|
||||
}
|
||||
|
||||
actual suspend fun platformClearWebviewData(pluginId: String?) {
|
||||
if (pluginId == null) return
|
||||
|
||||
CookieManager.getInstance().removeAllCookies(null)
|
||||
CookieManager.getInstance().flush()
|
||||
WebStorage.getInstance().deleteAllData()
|
||||
}
|
||||
@ -1,157 +1,164 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import io.github.kdroidfilter.webview.web.WebContent
|
||||
import io.github.kdroidfilter.webview.web.WebViewNavigator
|
||||
import io.github.kdroidfilter.webview.cookie.CookieManager
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
class WebViewController(val navigationCommands: NavigationCommands): KoinComponent {
|
||||
private val logger by injectLogger<WebViewController>()
|
||||
private var cookieManager: CookieManager? = null
|
||||
private val urlFlow = MutableStateFlow("")
|
||||
private val webViewCreated = MutableSharedFlow<Unit>(replay = 1)
|
||||
|
||||
suspend fun getCookies(url: String): List<Cookie> {
|
||||
if (cookieManager == null) {
|
||||
logger.w { "CookieManager is not initialized. Returning empty cookie list." }
|
||||
return emptyList()
|
||||
}
|
||||
val cookies = cookieManager!!.getCookies(url)
|
||||
val cookieList = mutableListOf<Cookie>()
|
||||
cookies.forEach {
|
||||
cookieList.add(
|
||||
Cookie(
|
||||
name = it.name,
|
||||
value = it.value,
|
||||
domain = it.domain ?: "",
|
||||
path = it.path,
|
||||
expiresAt = it.expiresDate,
|
||||
secure = it.isSecure ?: false,
|
||||
httpOnly = it.isHttpOnly ?: false
|
||||
)
|
||||
)
|
||||
}
|
||||
return cookieList
|
||||
}
|
||||
|
||||
private var content: String? = null
|
||||
private var isHtmlContent: Boolean = false
|
||||
fun getContent(): String? = content
|
||||
fun getWebContent(additionalHttpHeaders: Map<String, String> = emptyMap()): WebContent {
|
||||
if (content == null) throw IllegalStateException("Content is null. This should not happen as WebView should only be opened when content is set.")
|
||||
if (isHtmlContent) return WebContent.Data(data = content!!, mimeType = "text/html")
|
||||
return WebContent.Url(url = content!!, additionalHttpHeaders = additionalHttpHeaders)
|
||||
}
|
||||
|
||||
var webViewNavigator: WebViewNavigator? = null
|
||||
|
||||
fun emitUrlChange(url: String) {
|
||||
urlFlow.value = url
|
||||
}
|
||||
|
||||
fun emitWebViewCreated() {
|
||||
webViewCreated.tryEmit(Unit)
|
||||
}
|
||||
|
||||
fun setCookieManager(cookieManager: CookieManager) {
|
||||
this.cookieManager = cookieManager
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun closeWebview() {
|
||||
cookieManager = null
|
||||
content = null
|
||||
isHtmlContent = false
|
||||
webViewNavigator = null
|
||||
navigationCommands.pop(Routes.WebView)
|
||||
urlFlow.value = ""
|
||||
webViewCreated.resetReplayCache()
|
||||
_postMessagesFlow.resetReplayCache()
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
cookieManager = null
|
||||
content = null
|
||||
isHtmlContent = false
|
||||
webViewNavigator = null
|
||||
}
|
||||
|
||||
fun navigateTo(url: String) {
|
||||
if (this.content != null) {
|
||||
throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
|
||||
}
|
||||
this.content = url
|
||||
this.isHtmlContent = false
|
||||
navigationCommands.navigateTo(Routes.WebView)
|
||||
}
|
||||
|
||||
fun navigateToHTML(html: String) {
|
||||
if (this.content != null) {
|
||||
throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
|
||||
}
|
||||
this.content = html
|
||||
this.isHtmlContent = true
|
||||
navigationCommands.navigateTo(Routes.WebView)
|
||||
}
|
||||
|
||||
suspend fun evaluateJavascript(jsCode: String): String? {
|
||||
if (webViewNavigator == null) {
|
||||
throw IllegalStateException("WebView is not initialized. Cannot evaluate JavaScript.")
|
||||
}
|
||||
val completer = CompletableDeferred<String?>()
|
||||
try {
|
||||
webViewNavigator?.evaluateJavaScript(jsCode) { result ->
|
||||
completer.complete(result)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
completer.completeExceptionally(e)
|
||||
throw e
|
||||
}
|
||||
return completer.await()
|
||||
}
|
||||
|
||||
suspend fun clearData() {
|
||||
cookieManager?.removeAllCookies()
|
||||
cookieManager = null
|
||||
content = null
|
||||
isHtmlContent = false
|
||||
webViewNavigator = null
|
||||
}
|
||||
|
||||
val urlChangedFlow = urlFlow.asStateFlow()
|
||||
val webviewCreatedFlow = webViewCreated.asSharedFlow()
|
||||
private val _postMessagesFlow = MutableSharedFlow<String>(replay = 1)
|
||||
fun emitPostMessage(message: String) {
|
||||
_postMessagesFlow.tryEmit(message)
|
||||
}
|
||||
|
||||
val postMessagesFlow = _postMessagesFlow.asSharedFlow()
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import io.github.kdroidfilter.webview.web.WebContent
|
||||
import io.github.kdroidfilter.webview.web.WebViewNavigator
|
||||
import io.github.kdroidfilter.webview.cookie.CookieManager
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||
class WebViewController(val navigationCommands: NavigationCommands): KoinComponent {
|
||||
private val logger by injectLogger<WebViewController>()
|
||||
private var cookieManager: CookieManager? = null
|
||||
private val urlFlow = MutableStateFlow("")
|
||||
private val webViewCreated = MutableSharedFlow<Unit>(replay = 1)
|
||||
var currentPluginId: String? = null
|
||||
private set
|
||||
|
||||
suspend fun getCookies(url: String): List<Cookie> {
|
||||
if (cookieManager == null) {
|
||||
logger.w { "CookieManager is not initialized. Returning empty cookie list." }
|
||||
return emptyList()
|
||||
}
|
||||
val cookies = cookieManager!!.getCookies(url)
|
||||
val cookieList = mutableListOf<Cookie>()
|
||||
cookies.forEach {
|
||||
cookieList.add(
|
||||
Cookie(
|
||||
name = it.name,
|
||||
value = it.value,
|
||||
domain = it.domain ?: "",
|
||||
path = it.path,
|
||||
expiresAt = it.expiresDate,
|
||||
secure = it.isSecure ?: false,
|
||||
httpOnly = it.isHttpOnly ?: false
|
||||
)
|
||||
)
|
||||
}
|
||||
return cookieList
|
||||
}
|
||||
|
||||
private var content: String? = null
|
||||
private var isHtmlContent: Boolean = false
|
||||
fun getContent(): String? = content
|
||||
fun getWebContent(additionalHttpHeaders: Map<String, String> = emptyMap()): WebContent {
|
||||
if (content == null) throw IllegalStateException("Content is null. This should not happen as WebView should only be opened when content is set.")
|
||||
if (isHtmlContent) return WebContent.Data(data = content!!, mimeType = "text/html")
|
||||
return WebContent.Url(url = content!!, additionalHttpHeaders = additionalHttpHeaders)
|
||||
}
|
||||
|
||||
var webViewNavigator: WebViewNavigator? = null
|
||||
|
||||
fun emitUrlChange(url: String) {
|
||||
urlFlow.value = url
|
||||
}
|
||||
|
||||
fun emitWebViewCreated() {
|
||||
webViewCreated.tryEmit(Unit)
|
||||
}
|
||||
|
||||
fun setCookieManager(cookieManager: CookieManager) {
|
||||
this.cookieManager = cookieManager
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun closeWebview() {
|
||||
cookieManager = null
|
||||
content = null
|
||||
isHtmlContent = false
|
||||
webViewNavigator = null
|
||||
navigationCommands.pop(Routes.WebView)
|
||||
urlFlow.value = ""
|
||||
webViewCreated.resetReplayCache()
|
||||
_postMessagesFlow.resetReplayCache()
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
cookieManager = null
|
||||
content = null
|
||||
isHtmlContent = false
|
||||
webViewNavigator = null
|
||||
}
|
||||
|
||||
fun navigateTo(url: String, pluginId: String) {
|
||||
if (this.content != null) {
|
||||
throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
|
||||
}
|
||||
this.currentPluginId = pluginId
|
||||
this.content = url
|
||||
this.isHtmlContent = false
|
||||
navigationCommands.navigateTo(Routes.WebView)
|
||||
}
|
||||
|
||||
fun navigateToHTML(html: String, pluginId: String) {
|
||||
if (this.content != null) {
|
||||
throw IllegalStateException("WebView is already open. Please close the current WebView before navigating to a new URL.")
|
||||
}
|
||||
this.currentPluginId = pluginId
|
||||
this.content = html
|
||||
this.isHtmlContent = true
|
||||
navigationCommands.navigateTo(Routes.WebView)
|
||||
}
|
||||
|
||||
suspend fun evaluateJavascript(jsCode: String): String? {
|
||||
if (webViewNavigator == null) {
|
||||
throw IllegalStateException("WebView is not initialized. Cannot evaluate JavaScript.")
|
||||
}
|
||||
val completer = CompletableDeferred<String?>()
|
||||
try {
|
||||
webViewNavigator?.evaluateJavaScript(jsCode) { result ->
|
||||
completer.complete(result)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
completer.completeExceptionally(e)
|
||||
throw e
|
||||
}
|
||||
return completer.await()
|
||||
}
|
||||
|
||||
suspend fun clearData(pluginId: String? = null) {
|
||||
cookieManager?.removeAllCookies()
|
||||
val targetPluginId = pluginId ?: currentPluginId
|
||||
platformClearWebviewData(targetPluginId)
|
||||
cookieManager = null
|
||||
content = null
|
||||
isHtmlContent = false
|
||||
webViewNavigator = null
|
||||
currentPluginId = null
|
||||
}
|
||||
|
||||
val urlChangedFlow = urlFlow.asStateFlow()
|
||||
val webviewCreatedFlow = webViewCreated.asSharedFlow()
|
||||
private val _postMessagesFlow = MutableSharedFlow<String>(replay = 1)
|
||||
fun emitPostMessage(message: String) {
|
||||
_postMessagesFlow.tryEmit(message)
|
||||
}
|
||||
|
||||
val postMessagesFlow = _postMessagesFlow.asSharedFlow()
|
||||
}
|
||||
@ -1,22 +1,24 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
|
||||
expect fun platformWebviewConfig(webView: WebViewState)
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
|
||||
expect fun platformWebviewConfig(webView: WebViewState, pluginId: String?)
|
||||
|
||||
expect suspend fun platformClearWebviewData(pluginId: String?)
|
||||
|
||||
@ -1,228 +1,222 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler
|
||||
import io.github.kdroidfilter.webview.jsbridge.JsMessage
|
||||
import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge
|
||||
import io.github.kdroidfilter.webview.web.WebView
|
||||
import io.github.kdroidfilter.webview.web.rememberWebViewNavigator
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
import io.github.kdroidfilter.webview.web.WebViewNavigator
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.ChevronLeft
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import compose.icons.feathericons.X
|
||||
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PostMessageHandler(
|
||||
private val onMessageReceived: (String) -> Unit = {}
|
||||
) : IJsMessageHandler {
|
||||
override fun methodName(): String {
|
||||
return "sendMessage"
|
||||
}
|
||||
|
||||
override fun handle(
|
||||
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
|
||||
) {
|
||||
onMessageReceived(message.params)
|
||||
callback(message.params)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||
if (webViewController.getContent() == null) {
|
||||
// This should never happen, but just in case
|
||||
Text("No URL to load")
|
||||
return
|
||||
}
|
||||
|
||||
val state = remember {
|
||||
WebViewState(
|
||||
webViewController.getWebContent(
|
||||
additionalHttpHeaders = mapOf(
|
||||
"User-Agent" to UserAgents.random()
|
||||
)
|
||||
)
|
||||
)
|
||||
}.apply {
|
||||
this.content = webViewController.getWebContent()
|
||||
platformWebviewConfig(this)
|
||||
}
|
||||
|
||||
val navigator = rememberWebViewNavigator()
|
||||
val webViewBridge = rememberWebViewJsBridge(navigator)
|
||||
|
||||
val bridgeBootstrapScript = remember {
|
||||
"""
|
||||
(function() {
|
||||
if (typeof window.sendMessage !== "function") {
|
||||
window.sendMessage = function(message) {
|
||||
if (typeof message !== "string") {
|
||||
throw new TypeError("[window.sendMessage] Message must be a string");
|
||||
}
|
||||
window.kmpJsBridge.callNative("sendMessage", message);
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.bridgeReady) {
|
||||
const event = new CustomEvent("onBridgeReady");
|
||||
window.dispatchEvent(event);
|
||||
window.bridgeReady = true;
|
||||
}
|
||||
})();
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.lastLoadedUrl }.collect { url ->
|
||||
if (url != null) {
|
||||
webViewController.emitUrlChange(url)
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
webViewController.emitWebViewCreated()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.cookieManager, navigator) {
|
||||
webViewController.setCookieManager(cookieManager = state.cookieManager)
|
||||
webViewController.webViewNavigator = navigator
|
||||
}
|
||||
|
||||
LaunchedEffect(webViewBridge) {
|
||||
webViewBridge.register(PostMessageHandler { message ->
|
||||
webViewController.emitPostMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webViewController.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
topBar = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier.height(56.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
navigator.navigateBack()
|
||||
}, enabled = navigator.canGoBack
|
||||
) {
|
||||
Icon(
|
||||
FeatherIcons.ChevronLeft,
|
||||
contentDescription = "Go back to browser history"
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
navigator.navigateForward()
|
||||
}, enabled = navigator.canGoForward
|
||||
) {
|
||||
Icon(
|
||||
FeatherIcons.ChevronRight,
|
||||
contentDescription = "Go forward to browser history"
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
|
||||
) {
|
||||
BasicTextField(
|
||||
value = state.lastLoadedUrl ?: "",
|
||||
onValueChange = {}, // Read-only
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Start
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
||||
.wrapContentHeight(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
webViewController.closeWebview()
|
||||
}) {
|
||||
Icon(FeatherIcons.X, contentDescription = "Close WebView")
|
||||
}
|
||||
}
|
||||
}) { innerPadding ->
|
||||
WebView(
|
||||
state = state,
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
||||
navigator = navigator,
|
||||
webViewJsBridge = webViewBridge,
|
||||
onCreated = { webView ->
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
|
||||
},
|
||||
factory = null
|
||||
)
|
||||
}
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.ChevronLeft
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import compose.icons.feathericons.X
|
||||
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
||||
import io.github.kdroidfilter.webview.jsbridge.IJsMessageHandler
|
||||
import io.github.kdroidfilter.webview.jsbridge.JsMessage
|
||||
import io.github.kdroidfilter.webview.jsbridge.rememberWebViewJsBridge
|
||||
import io.github.kdroidfilter.webview.web.WebView
|
||||
import io.github.kdroidfilter.webview.web.WebViewNavigator
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
import io.github.kdroidfilter.webview.web.rememberWebViewNavigator
|
||||
|
||||
class PostMessageHandler(
|
||||
private val onMessageReceived: (String) -> Unit = {}
|
||||
) : IJsMessageHandler {
|
||||
override fun methodName(): String {
|
||||
return "sendMessage"
|
||||
}
|
||||
|
||||
override fun handle(
|
||||
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
|
||||
) {
|
||||
onMessageReceived(message.params)
|
||||
callback(message.params)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||
if (webViewController.getContent() == null) {
|
||||
// This should never happen, but just in case
|
||||
Text("No URL to load")
|
||||
return
|
||||
}
|
||||
|
||||
val state = remember {
|
||||
WebViewState(
|
||||
webViewController.getWebContent(
|
||||
additionalHttpHeaders = mapOf(
|
||||
"User-Agent" to UserAgents.random()
|
||||
)
|
||||
)
|
||||
)
|
||||
}.apply {
|
||||
this.content = webViewController.getWebContent()
|
||||
platformWebviewConfig(this, webViewController.currentPluginId)
|
||||
}
|
||||
|
||||
val navigator = rememberWebViewNavigator()
|
||||
val webViewBridge = rememberWebViewJsBridge(navigator)
|
||||
|
||||
val bridgeBootstrapScript = remember {
|
||||
"""
|
||||
(function() {
|
||||
if (typeof window.sendMessage !== "function") {
|
||||
window.sendMessage = function(message) {
|
||||
if (typeof message !== "string") {
|
||||
throw new TypeError("[window.sendMessage] Message must be a string");
|
||||
}
|
||||
window.kmpJsBridge.callNative("sendMessage", message);
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.bridgeReady) {
|
||||
const event = new CustomEvent("onBridgeReady");
|
||||
window.dispatchEvent(event);
|
||||
window.bridgeReady = true;
|
||||
}
|
||||
})();
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
snapshotFlow { state.lastLoadedUrl }.collect { url ->
|
||||
if (url != null) {
|
||||
webViewController.emitUrlChange(url)
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
webViewController.emitWebViewCreated()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.cookieManager, navigator) {
|
||||
webViewController.setCookieManager(cookieManager = state.cookieManager)
|
||||
webViewController.webViewNavigator = navigator
|
||||
}
|
||||
|
||||
LaunchedEffect(webViewBridge) {
|
||||
webViewBridge.register(PostMessageHandler { message ->
|
||||
webViewController.emitPostMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webViewController.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
topBar = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier.height(56.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
navigator.navigateBack()
|
||||
}, enabled = navigator.canGoBack
|
||||
) {
|
||||
Icon(
|
||||
FeatherIcons.ChevronLeft,
|
||||
contentDescription = "Go back to browser history"
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
navigator.navigateForward()
|
||||
}, enabled = navigator.canGoForward
|
||||
) {
|
||||
Icon(
|
||||
FeatherIcons.ChevronRight,
|
||||
contentDescription = "Go forward to browser history"
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
|
||||
) {
|
||||
BasicTextField(
|
||||
value = state.lastLoadedUrl ?: "",
|
||||
onValueChange = {}, // Read-only
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Start
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
||||
.wrapContentHeight(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
webViewController.closeWebview()
|
||||
}) {
|
||||
Icon(FeatherIcons.X, contentDescription = "Close WebView")
|
||||
}
|
||||
}
|
||||
}) { innerPadding ->
|
||||
WebView(
|
||||
state = state,
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
||||
navigator = navigator,
|
||||
webViewJsBridge = webViewBridge,
|
||||
onCreated = { webView ->
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
|
||||
},
|
||||
factory = null
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,345 +1,345 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline
|
||||
|
||||
import app.cash.zipline.Zipline
|
||||
import app.cash.zipline.ZiplineService
|
||||
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
||||
import app.cash.zipline.loader.LoadResult
|
||||
import app.cash.zipline.loader.ManifestVerifier
|
||||
import app.cash.zipline.loader.ZiplineLoader
|
||||
import dev.krtirtho.plugin_interfaces.core.Initializer
|
||||
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginCapability
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.decodeURLQueryComponent
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.Path.Companion.toPath
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of a Zipline plugin.
|
||||
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
||||
* Anything it provides, must be called within that dispatcher context.
|
||||
* The [use] function must be used.
|
||||
*
|
||||
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
|
||||
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
|
||||
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
|
||||
*
|
||||
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
|
||||
*/
|
||||
open class ZiplinePluginService(
|
||||
val applicationName: String,
|
||||
private val manifestUrl: String,
|
||||
private val pluginInfo: PluginEntry,
|
||||
) : PluginService, KoinComponent {
|
||||
|
||||
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
||||
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
|
||||
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
||||
// We use a custom EventListener to increase maxStackSize right after the Zipline
|
||||
// instance is created, before any modules are loaded.
|
||||
private val ziplineDispatcher = createZiplineDispatcher()
|
||||
|
||||
private fun trace(event: String) {
|
||||
logger.d { "[$applicationName] $event" }
|
||||
}
|
||||
|
||||
private val logger by injectLogger<ZiplinePluginService>()
|
||||
private val webViewController: WebViewController by inject()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
|
||||
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
logger.e(throwable) { "Zipline Engine Error" }
|
||||
}
|
||||
private val lifecycleMutex = Mutex()
|
||||
private var ziplineLoader: ZiplineLoader
|
||||
private var ziplineInstance: Zipline? = null
|
||||
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||
|
||||
init {
|
||||
val manifestPath = URLBuilder(manifestUrl)
|
||||
val baseDir =
|
||||
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
|
||||
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
|
||||
ziplineLoader = ZiplineLoader(
|
||||
dispatcher = ziplineDispatcher.dispatcher,
|
||||
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
|
||||
httpClient = FileSystemHTTPClient(baseDir)
|
||||
)
|
||||
}
|
||||
|
||||
private val realHttpClientAPI = RealHttpClientAPI()
|
||||
private val realWebViewAPI = RealWebViewAPI(scope, webViewController)
|
||||
|
||||
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
|
||||
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
|
||||
private val systemInformationAPI = RealSystemInformationAPI()
|
||||
|
||||
private val loggedInStateFlow = MutableStateFlow(false)
|
||||
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
|
||||
|
||||
private fun bindHostServices(zipline: Zipline) {
|
||||
trace("initializer(): binding host APIs")
|
||||
logger.d { "[$applicationName] Binding host APIs in initializer" }
|
||||
try {
|
||||
// Basic APIs
|
||||
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
|
||||
zipline.bind<SystemInformationAPI>(
|
||||
SystemInformationAPI_SERVICE_NAME,
|
||||
systemInformationAPI
|
||||
)
|
||||
|
||||
// Conditional APIs based on plugin capabilities
|
||||
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
|
||||
zipline.bind<HttpClientAPI>(
|
||||
HttpClientAPI_SERVICE_NAME,
|
||||
realHttpClientAPI
|
||||
)
|
||||
}
|
||||
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
|
||||
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
|
||||
}
|
||||
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
|
||||
zipline.bind<PersistedStorageAPI>(
|
||||
PersistedStorageAPI_SERVICE_NAME,
|
||||
persistedStorageAPI
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumePluginServices(result: LoadResult.Success) {
|
||||
trace("start(): loadOnce success")
|
||||
val apiMap =
|
||||
buildMap<KClass<*>, ZiplineService> {
|
||||
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
|
||||
|
||||
if (PluginAbility.METADATA in pluginInfo.abilities) {
|
||||
put(
|
||||
MetadataUserAPI::class,
|
||||
result.zipline.take<MetadataUserAPI>(
|
||||
MetadataUserAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataTrackAPI::class,
|
||||
result.zipline.take<MetadataTrackAPI>(
|
||||
MetadataTrackAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataAlbumAPI::class,
|
||||
result.zipline.take<MetadataAlbumAPI>(
|
||||
MetadataAlbumAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataArtistAPI::class,
|
||||
result.zipline.take<MetadataArtistAPI>(
|
||||
MetadataArtistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataPlaylistAPI::class,
|
||||
result.zipline.take<MetadataPlaylistAPI>(
|
||||
MetadataPlaylistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataBrowseAPI::class,
|
||||
result.zipline.take<MetadataBrowseAPI>(
|
||||
MetadataBrowseAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataSearchAPI::class,
|
||||
result.zipline.take<MetadataSearchAPI>(
|
||||
MetadataSearchAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.AUDIO in pluginInfo.abilities) {
|
||||
put(
|
||||
AudioAPI::class,
|
||||
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.LYRICS in pluginInfo.abilities) {
|
||||
put(
|
||||
LyricsAPI::class,
|
||||
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
|
||||
put(
|
||||
ScrobbleAPI::class,
|
||||
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
serviceRegistry.putAll(apiMap)
|
||||
trace("start(): API ready")
|
||||
}
|
||||
|
||||
private fun runLogInFlowObservers() = scope.launch {
|
||||
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
|
||||
coreAPI.loggedInFlow.collect { isLoggedIn ->
|
||||
loggedInStateFlow.value = isLoggedIn
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun start() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("start(): entered")
|
||||
if (serviceRegistry.isNotEmpty()) {
|
||||
trace("start(): already started, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
trace("start(): inside zipline dispatcher before loadOnce")
|
||||
val result = ziplineLoader.loadOnce(
|
||||
applicationName = applicationName,
|
||||
manifestUrl = manifestUrl,
|
||||
freshnessChecker = DefaultFreshnessCheckerNotFresh,
|
||||
)
|
||||
when (result) {
|
||||
is LoadResult.Success -> {
|
||||
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
||||
ziplineInstance = result.zipline
|
||||
// Now we consume the initializer
|
||||
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
||||
// Bind host services before initialization, so plugins can use them in their initializer
|
||||
bindHostServices(result.zipline)
|
||||
trace("start(): calling initializer.initialize()")
|
||||
runCatching { initializer.initialize() }
|
||||
.onSuccess {
|
||||
consumePluginServices(result)
|
||||
runLogInFlowObservers()
|
||||
}
|
||||
.onFailure { e ->
|
||||
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
is LoadResult.Failure -> {
|
||||
trace("start(): loadOnce failure: ${result.exception}")
|
||||
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
|
||||
throw result.exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun stop() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("stop(): entered")
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
ziplineInstance?.close()
|
||||
ziplineInstance = null
|
||||
for (service in serviceRegistry.values) {
|
||||
try {
|
||||
trace("stop(): closing service ${service::class.simpleName}")
|
||||
service.close()
|
||||
} catch (_: Exception) {
|
||||
trace("stop(): error closing service ${service::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
serviceRegistry.clear()
|
||||
scope.cancel()
|
||||
loggedInStateFlow.value = false
|
||||
ziplineDispatcher.close()
|
||||
trace("stop(): completed")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
||||
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
|
||||
// Create the scope with the current registry
|
||||
val scope = PluginServiceScope(serviceRegistry)
|
||||
// Execute the block with 'scope' as 'this'
|
||||
scope.block()
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline
|
||||
|
||||
import app.cash.zipline.Zipline
|
||||
import app.cash.zipline.ZiplineService
|
||||
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
||||
import app.cash.zipline.loader.LoadResult
|
||||
import app.cash.zipline.loader.ManifestVerifier
|
||||
import app.cash.zipline.loader.ZiplineLoader
|
||||
import dev.krtirtho.plugin_interfaces.core.Initializer
|
||||
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginCapability
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.decodeURLQueryComponent
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.Path.Companion.toPath
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of a Zipline plugin.
|
||||
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
||||
* Anything it provides, must be called within that dispatcher context.
|
||||
* The [use] function must be used.
|
||||
*
|
||||
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
|
||||
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
|
||||
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
|
||||
*
|
||||
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
|
||||
*/
|
||||
open class ZiplinePluginService(
|
||||
val applicationName: String,
|
||||
private val manifestUrl: String,
|
||||
private val pluginInfo: PluginEntry,
|
||||
) : PluginService, KoinComponent {
|
||||
|
||||
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
||||
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
|
||||
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
||||
// We use a custom EventListener to increase maxStackSize right after the Zipline
|
||||
// instance is created, before any modules are loaded.
|
||||
private val ziplineDispatcher = createZiplineDispatcher()
|
||||
|
||||
private fun trace(event: String) {
|
||||
logger.d { "[$applicationName] $event" }
|
||||
}
|
||||
|
||||
private val logger by injectLogger<ZiplinePluginService>()
|
||||
private val webViewController: WebViewController by inject()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
|
||||
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
logger.e(throwable) { "Zipline Engine Error" }
|
||||
}
|
||||
private val lifecycleMutex = Mutex()
|
||||
private var ziplineLoader: ZiplineLoader
|
||||
private var ziplineInstance: Zipline? = null
|
||||
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||
|
||||
init {
|
||||
val manifestPath = URLBuilder(manifestUrl)
|
||||
val baseDir =
|
||||
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
|
||||
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
|
||||
ziplineLoader = ZiplineLoader(
|
||||
dispatcher = ziplineDispatcher.dispatcher,
|
||||
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
|
||||
httpClient = FileSystemHTTPClient(baseDir)
|
||||
)
|
||||
}
|
||||
|
||||
private val realHttpClientAPI = RealHttpClientAPI()
|
||||
private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
|
||||
|
||||
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
|
||||
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
|
||||
private val systemInformationAPI = RealSystemInformationAPI()
|
||||
|
||||
private val loggedInStateFlow = MutableStateFlow(false)
|
||||
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
|
||||
|
||||
private fun bindHostServices(zipline: Zipline) {
|
||||
trace("initializer(): binding host APIs")
|
||||
logger.d { "[$applicationName] Binding host APIs in initializer" }
|
||||
try {
|
||||
// Basic APIs
|
||||
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
|
||||
zipline.bind<SystemInformationAPI>(
|
||||
SystemInformationAPI_SERVICE_NAME,
|
||||
systemInformationAPI
|
||||
)
|
||||
|
||||
// Conditional APIs based on plugin capabilities
|
||||
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
|
||||
zipline.bind<HttpClientAPI>(
|
||||
HttpClientAPI_SERVICE_NAME,
|
||||
realHttpClientAPI
|
||||
)
|
||||
}
|
||||
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
|
||||
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
|
||||
}
|
||||
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
|
||||
zipline.bind<PersistedStorageAPI>(
|
||||
PersistedStorageAPI_SERVICE_NAME,
|
||||
persistedStorageAPI
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumePluginServices(result: LoadResult.Success) {
|
||||
trace("start(): loadOnce success")
|
||||
val apiMap =
|
||||
buildMap<KClass<*>, ZiplineService> {
|
||||
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
|
||||
|
||||
if (PluginAbility.METADATA in pluginInfo.abilities) {
|
||||
put(
|
||||
MetadataUserAPI::class,
|
||||
result.zipline.take<MetadataUserAPI>(
|
||||
MetadataUserAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataTrackAPI::class,
|
||||
result.zipline.take<MetadataTrackAPI>(
|
||||
MetadataTrackAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataAlbumAPI::class,
|
||||
result.zipline.take<MetadataAlbumAPI>(
|
||||
MetadataAlbumAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataArtistAPI::class,
|
||||
result.zipline.take<MetadataArtistAPI>(
|
||||
MetadataArtistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataPlaylistAPI::class,
|
||||
result.zipline.take<MetadataPlaylistAPI>(
|
||||
MetadataPlaylistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataBrowseAPI::class,
|
||||
result.zipline.take<MetadataBrowseAPI>(
|
||||
MetadataBrowseAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataSearchAPI::class,
|
||||
result.zipline.take<MetadataSearchAPI>(
|
||||
MetadataSearchAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.AUDIO in pluginInfo.abilities) {
|
||||
put(
|
||||
AudioAPI::class,
|
||||
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.LYRICS in pluginInfo.abilities) {
|
||||
put(
|
||||
LyricsAPI::class,
|
||||
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
|
||||
put(
|
||||
ScrobbleAPI::class,
|
||||
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
serviceRegistry.putAll(apiMap)
|
||||
trace("start(): API ready")
|
||||
}
|
||||
|
||||
private fun runLogInFlowObservers() = scope.launch {
|
||||
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
|
||||
coreAPI.loggedInFlow.collect { isLoggedIn ->
|
||||
loggedInStateFlow.value = isLoggedIn
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun start() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("start(): entered")
|
||||
if (serviceRegistry.isNotEmpty()) {
|
||||
trace("start(): already started, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
trace("start(): inside zipline dispatcher before loadOnce")
|
||||
val result = ziplineLoader.loadOnce(
|
||||
applicationName = applicationName,
|
||||
manifestUrl = manifestUrl,
|
||||
freshnessChecker = DefaultFreshnessCheckerNotFresh,
|
||||
)
|
||||
when (result) {
|
||||
is LoadResult.Success -> {
|
||||
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
||||
ziplineInstance = result.zipline
|
||||
// Now we consume the initializer
|
||||
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
||||
// Bind host services before initialization, so plugins can use them in their initializer
|
||||
bindHostServices(result.zipline)
|
||||
trace("start(): calling initializer.initialize()")
|
||||
runCatching { initializer.initialize() }
|
||||
.onSuccess {
|
||||
consumePluginServices(result)
|
||||
runLogInFlowObservers()
|
||||
}
|
||||
.onFailure { e ->
|
||||
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
is LoadResult.Failure -> {
|
||||
trace("start(): loadOnce failure: ${result.exception}")
|
||||
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
|
||||
throw result.exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun stop() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("stop(): entered")
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
ziplineInstance?.close()
|
||||
ziplineInstance = null
|
||||
for (service in serviceRegistry.values) {
|
||||
try {
|
||||
trace("stop(): closing service ${service::class.simpleName}")
|
||||
service.close()
|
||||
} catch (_: Exception) {
|
||||
trace("stop(): error closing service ${service::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
serviceRegistry.clear()
|
||||
scope.cancel()
|
||||
loggedInStateFlow.value = false
|
||||
ziplineDispatcher.close()
|
||||
trace("stop(): completed")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
||||
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
|
||||
// Create the scope with the current registry
|
||||
val scope = PluginServiceScope(serviceRegistry)
|
||||
// Execute the block with 'scope' as 'this'
|
||||
scope.block()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,76 +1,74 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.host_apis
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class RealWebViewAPI(
|
||||
private val scope: CoroutineScope,
|
||||
private val webViewController: WebViewController,
|
||||
) : WebViewAPI {
|
||||
override fun navigateTo(url: String) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.navigateTo(url)
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateToHTML(html: String) {
|
||||
scope.launch {
|
||||
webViewController.navigateToHTML(html)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCookies(url: String): List<Cookie> {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.getCookies(url)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun evaluateJavaScript(script: String): String? {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.evaluateJavascript(script)
|
||||
}
|
||||
}
|
||||
|
||||
override fun urlChangeFlow(): Flow<String> {
|
||||
return webViewController.urlChangedFlow
|
||||
}
|
||||
override fun webviewCreatedFlow(): Flow<Unit> {
|
||||
return webViewController.webviewCreatedFlow
|
||||
}
|
||||
override fun postMessagesFlow(): Flow<String> {
|
||||
return webViewController.postMessagesFlow
|
||||
}
|
||||
|
||||
override fun exitWebView() {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.closeWebview()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.zipline.host_apis
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class RealWebViewAPI(
|
||||
private val scope: CoroutineScope,
|
||||
private val webViewController: WebViewController,
|
||||
private val pluginId: String,
|
||||
) : WebViewAPI {
|
||||
override fun navigateTo(url: String) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.navigateTo(url, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateToHTML(html: String) {
|
||||
scope.launch {
|
||||
webViewController.navigateToHTML(html, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCookies(url: String): List<Cookie> {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.getCookies(url)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun evaluateJavaScript(script: String): String? {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.evaluateJavascript(script)
|
||||
}
|
||||
}
|
||||
|
||||
override fun urlChangeFlow(): Flow<String> {
|
||||
return webViewController.urlChangedFlow
|
||||
}
|
||||
override fun webviewCreatedFlow(): Flow<Unit> {
|
||||
return webViewController.webviewCreatedFlow
|
||||
}
|
||||
override fun postMessagesFlow(): Flow<String> {
|
||||
return webViewController.postMessagesFlow
|
||||
}
|
||||
|
||||
override fun exitWebView() {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.closeWebview()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,23 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
|
||||
actual fun platformWebviewConfig(webView: WebViewState) {
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSHTTPCookieStorage
|
||||
import platform.Foundation.distantPast
|
||||
import platform.WebKit.WKWebsiteDataStore
|
||||
import platform.WebKit.WKWebsiteDataTypeCookies
|
||||
import platform.WebKit.WKWebsiteDataTypeLocalStorage
|
||||
|
||||
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual suspend fun platformClearWebviewData(pluginId: String?) {
|
||||
if (pluginId == null) return
|
||||
|
||||
val dataStore = WKWebsiteDataStore.defaultDataStore()
|
||||
val dataTypes = setOf(WKWebsiteDataTypeCookies, WKWebsiteDataTypeLocalStorage)
|
||||
dataStore.removeDataOfTypes(dataTypes, NSDate.distantPast) {}
|
||||
|
||||
NSHTTPCookieStorage.sharedHTTPCookieStorage.removeCookiesSinceDate(NSDate.distantPast)
|
||||
}
|
||||
@ -1,31 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import dev.krtirtho.spotube.core.paths.Paths
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
import io.github.vinceglb.filekit.utils.div
|
||||
import io.github.vinceglb.filekit.utils.toPath
|
||||
import org.koin.core.context.GlobalContext
|
||||
|
||||
actual fun platformWebviewConfig(webView: WebViewState) {
|
||||
val paths = GlobalContext.get().get<Paths>()
|
||||
|
||||
webView.webSettings.desktopWebSettings.dataDirectory =
|
||||
(paths.getApplicationCacheDirPath().toPath() / "webview_data").toString()
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package dev.krtirtho.spotube.core.webview
|
||||
|
||||
import dev.krtirtho.spotube.core.paths.Paths
|
||||
import io.github.kdroidfilter.webview.web.WebViewState
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toPath
|
||||
import org.koin.core.context.GlobalContext
|
||||
|
||||
actual fun platformWebviewConfig(webView: WebViewState, pluginId: String?) {
|
||||
val paths = GlobalContext.get().get<Paths>()
|
||||
|
||||
val baseDir = "${paths.getApplicationCacheDirPath()}/webview_data".toPath()
|
||||
val dataDir = if (pluginId != null) baseDir / pluginId else baseDir
|
||||
webView.webSettings.desktopWebSettings.dataDirectory = dataDir.toString()
|
||||
}
|
||||
|
||||
actual suspend fun platformClearWebviewData(pluginId: String?) {
|
||||
if (pluginId == null) return
|
||||
val paths = GlobalContext.get().get<Paths>()
|
||||
val dataDirStr = "${paths.getApplicationCacheDirPath()}/webview_data/$pluginId"
|
||||
val dataDir = dataDirStr.toPath()
|
||||
if (FileSystem.SYSTEM.exists(dataDir)) {
|
||||
FileSystem.SYSTEM.deleteRecursively(dataDir)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user