mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-04 11:24:35 -05:00
Switch to JCEF (#2038)
* Switch to JCEF This is a full implementation, but it does not yet include downloading CEF as KCEF did * Download CEF automatically * Handle and propagate CEF init errors * Lint * Simplify jcef version extract * CEF: Download async * Copy StartupAsync to support handling errors Startup failures are simply swallowed, since they are recorded in the future, but there is no way to get that exception * CEF: Search for release file recursively On Mac, the file is buried a bit deeper than first level, like on Win and Linux * KcefWebViewProvider: Suppress deprecation We need to send those events, even if they are deprecated * Update readme * Optimize imports * Suggestion Co-authored-by: Mitchell Syer <syer10@users.noreply.github.com> * Refactor: stick to `Path` instead of `File` Also extracts the downloading of CEF to a separate method * Lint * Support disabling CEF Co-authored-by: Kolby Moroz Liebl <31669092+kolbyml@users.noreply.github.com> * Move JBR version to build constants Allows embedding into Manifest so docker can later extract the proper version * Create test to verify JCEF dependency matches downloaded JBR * Update server/src/main/kotlin/suwayomi/tachidesk/server/util/CEFManager.kt Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com> * Fix compile, apply Path suggestions * Download progress * Lint * Fix exception on non-posix * Delete recursively Others can be non-empty * Support disabling CEF at will Not really functional, but nice * Fix test * Exclude masstest unless explicitly requested * PR-CI: Run tests * Add Changelog entry --------- Co-authored-by: Mitchell Syer <syer10@users.noreply.github.com> Co-authored-by: Kolby Moroz Liebl <31669092+kolbyml@users.noreply.github.com>
This commit is contained in:
4
.github/workflows/build_pull_request.yml
vendored
4
.github/workflows/build_pull_request.yml
vendored
@@ -96,6 +96,10 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
|
|
||||||
|
- name: "Run tests"
|
||||||
|
working-directory: master
|
||||||
|
run: ./gradlew test --stacktrace
|
||||||
|
|
||||||
check_docs:
|
check_docs:
|
||||||
name: Validate that all options are documented
|
name: Validate that all options are documented
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package xyz.nulldev.androidcompat.webkit
|
||||||
|
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import org.cef.CefApp
|
||||||
|
import org.cef.CefClient
|
||||||
|
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
object CefHelper {
|
||||||
|
val cefApp = MutableStateFlow<Result<CefApp?>>(Result.success(null))
|
||||||
|
|
||||||
|
suspend fun createClient(): CefClient {
|
||||||
|
val app = waitForInit().first()
|
||||||
|
val client = app.createClient()
|
||||||
|
JsHandler(client) // This adds itself to a global map
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
fun waitForInit() =
|
||||||
|
callbackFlow<CefApp> {
|
||||||
|
val app = cefApp.first { it.isFailure || it.getOrThrow() != null }.getOrThrow()!!
|
||||||
|
app.onInitialization {
|
||||||
|
logger.debug { "CEF: Initialization state $it" }
|
||||||
|
when (it) {
|
||||||
|
CefApp.CefAppState.INITIALIZED -> {
|
||||||
|
trySend(app)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
CefApp.CefAppState.SHUTTING_DOWN, CefApp.CefAppState.TERMINATED -> {
|
||||||
|
close(CefException("Shutting down"))
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
awaitClose {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CefException(
|
||||||
|
msg: String,
|
||||||
|
) : Exception(msg)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package xyz.nulldev.androidcompat.webkit
|
||||||
|
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.cef.CefClient
|
||||||
|
import org.cef.browser.CefBrowser
|
||||||
|
import org.cef.browser.CefFrame
|
||||||
|
import org.cef.browser.CefMessageRouter
|
||||||
|
import org.cef.callback.CefQueryCallback
|
||||||
|
import org.cef.handler.CefMessageRouterHandlerAdapter
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
private val jsHandler: MutableMap<CefClient, JsHandler> = mutableMapOf()
|
||||||
|
|
||||||
|
fun CefBrowser.evaluateJavaScript(
|
||||||
|
expression: String,
|
||||||
|
cb: (String?) -> Unit,
|
||||||
|
) = jsHandler[this.client]!!.eval(this, expression, cb)
|
||||||
|
|
||||||
|
fun CefBrowser.dispose() {
|
||||||
|
stopLoad()
|
||||||
|
setCloseAllowed()
|
||||||
|
close(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
class JsHandler : CefMessageRouterHandlerAdapter {
|
||||||
|
private val handler: MutableMap<String, (String?) -> Unit> = mutableMapOf()
|
||||||
|
|
||||||
|
constructor(client: CefClient) {
|
||||||
|
val config = CefMessageRouter.CefMessageRouterConfig()
|
||||||
|
config.jsQueryFunction = QUERY_FN
|
||||||
|
config.jsCancelFunction = QUERY_CANCEL_FN
|
||||||
|
client.addMessageRouter(CefMessageRouter.create(config, this))
|
||||||
|
jsHandler[client] = this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun eval(
|
||||||
|
frame: CefFrame,
|
||||||
|
expression: String,
|
||||||
|
cb: (String?) -> Unit,
|
||||||
|
) {
|
||||||
|
val id = Random.nextBytes(48).toHexString()
|
||||||
|
handler[id] = cb
|
||||||
|
frame.executeJavaScript(expression.toCode(id), "about:cef", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun eval(
|
||||||
|
browser: CefBrowser,
|
||||||
|
expression: String,
|
||||||
|
cb: (String?) -> Unit,
|
||||||
|
) {
|
||||||
|
val id = Random.nextBytes(48).toHexString()
|
||||||
|
handler[id] = cb
|
||||||
|
browser.executeJavaScript(expression.toCode(id), "about:cef", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onQuery(
|
||||||
|
browser: CefBrowser?,
|
||||||
|
frame: CefFrame?,
|
||||||
|
queryId: Long,
|
||||||
|
request: String?,
|
||||||
|
persistent: Boolean,
|
||||||
|
callback: CefQueryCallback?,
|
||||||
|
): Boolean {
|
||||||
|
super.onQuery(browser, frame, queryId, request, persistent, callback)
|
||||||
|
|
||||||
|
if (request != null) {
|
||||||
|
val invoke =
|
||||||
|
try {
|
||||||
|
Json.decodeFromString<FunctionCall>(request)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn(e) { "Invalid request received" }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val handler = handler.remove(invoke.id) ?: return false
|
||||||
|
handler(invoke.result)
|
||||||
|
callback?.success("")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class FunctionCall(
|
||||||
|
val id: String,
|
||||||
|
val result: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val QUERY_FN = "__\$_evalQuery"
|
||||||
|
const val QUERY_CANCEL_FN = "__\$_evalQueryCancel"
|
||||||
|
|
||||||
|
private fun Char.isLineBreak(): Boolean = this == '\n' || this == '\r'
|
||||||
|
|
||||||
|
private fun String.containsLineBreak(): Boolean =
|
||||||
|
this.any {
|
||||||
|
it.isLineBreak()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.asFunctionBody(): String =
|
||||||
|
let { expression ->
|
||||||
|
when {
|
||||||
|
expression.containsLineBreak() -> expression
|
||||||
|
expression.trim().startsWith("return", false) -> expression
|
||||||
|
else -> "return $expression"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toCode(id: String): String =
|
||||||
|
"""
|
||||||
|
function payload() {
|
||||||
|
${this.asFunctionBody()}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var result = payload();
|
||||||
|
|
||||||
|
window.${QUERY_FN}({
|
||||||
|
request: JSON.stringify({ id: "$id", result }),
|
||||||
|
onSuccess: function (response) {},
|
||||||
|
onFailure: function (error_code, error_message) {}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to eval $id", e)
|
||||||
|
window.${QUERY_CANCEL_FN}({
|
||||||
|
request: JSON.stringify({ id: "$id", error: ""+e }),
|
||||||
|
onSuccess: function (response) {},
|
||||||
|
onFailure: function (error_code, error_message) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,11 +51,10 @@ import android.webkit.WebViewProvider.ScrollDelegate
|
|||||||
import android.webkit.WebViewProvider.ViewDelegate
|
import android.webkit.WebViewProvider.ViewDelegate
|
||||||
import android.webkit.WebViewRenderProcess
|
import android.webkit.WebViewRenderProcess
|
||||||
import android.webkit.WebViewRenderProcessClient
|
import android.webkit.WebViewRenderProcessClient
|
||||||
import dev.datlag.kcef.KCEF
|
import kotlinx.coroutines.runBlocking
|
||||||
import dev.datlag.kcef.KCEFBrowser
|
|
||||||
import dev.datlag.kcef.KCEFClient
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.cef.CefClient
|
||||||
import org.cef.CefSettings
|
import org.cef.CefSettings
|
||||||
import org.cef.browser.CefBrowser
|
import org.cef.browser.CefBrowser
|
||||||
import org.cef.browser.CefFrame
|
import org.cef.browser.CefFrame
|
||||||
@@ -87,7 +86,7 @@ import java.io.BufferedWriter
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
import kotlin.reflect.KClass
|
import kotlin.math.min
|
||||||
import kotlin.reflect.KFunction
|
import kotlin.reflect.KFunction
|
||||||
import kotlin.reflect.full.declaredMemberFunctions
|
import kotlin.reflect.full.declaredMemberFunctions
|
||||||
import kotlin.reflect.jvm.javaMethod
|
import kotlin.reflect.jvm.javaMethod
|
||||||
@@ -102,8 +101,8 @@ class KcefWebViewProvider(
|
|||||||
private val urlHttpMapping: MutableMap<String, String> = mutableMapOf()
|
private val urlHttpMapping: MutableMap<String, String> = mutableMapOf()
|
||||||
private var initialRequestData: InitialRequestData? = null
|
private var initialRequestData: InitialRequestData? = null
|
||||||
|
|
||||||
private var kcefClient: KCEFClient? = null
|
private var kcefClient: CefClient? = null
|
||||||
private var browser: KCEFBrowser? = null
|
private var browser: CefBrowser? = null
|
||||||
|
|
||||||
private val handler = Handler(view.webViewLooper)
|
private val handler = Handler(view.webViewLooper)
|
||||||
|
|
||||||
@@ -115,8 +114,8 @@ class KcefWebViewProvider(
|
|||||||
private val initHandler: InitBrowserHandler by KoinPlatformTools.defaultContext().get().inject()
|
private val initHandler: InitBrowserHandler by KoinPlatformTools.defaultContext().get().inject()
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface InitBrowserHandler {
|
interface InitBrowserHandler {
|
||||||
public fun init(provider: KcefWebViewProvider): Unit
|
fun init(provider: KcefWebViewProvider): Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class InitialRequestData(
|
private data class InitialRequestData(
|
||||||
@@ -192,7 +191,7 @@ class KcefWebViewProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class DisplayHandler : CefDisplayHandlerAdapter() {
|
private class DisplayHandler : CefDisplayHandlerAdapter() {
|
||||||
override fun onConsoleMessage(
|
override fun onConsoleMessage(
|
||||||
browser: CefBrowser,
|
browser: CefBrowser,
|
||||||
level: CefSettings.LogSeverity,
|
level: CefSettings.LogSeverity,
|
||||||
@@ -220,6 +219,7 @@ class KcefWebViewProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
private inner class LoadHandler : CefLoadHandlerAdapter() {
|
private inner class LoadHandler : CefLoadHandlerAdapter() {
|
||||||
override fun onLoadEnd(
|
override fun onLoadEnd(
|
||||||
browser: CefBrowser,
|
browser: CefBrowser,
|
||||||
@@ -366,7 +366,7 @@ class KcefWebViewProvider(
|
|||||||
callback: CefCallback,
|
callback: CefCallback,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val data = resolvedData ?: return false
|
val data = resolvedData ?: return false
|
||||||
val bytesToTransfer = Math.min(bytesToRead, data.size - readOffset)
|
val bytesToTransfer = min(bytesToRead, data.size - readOffset)
|
||||||
Log.v(
|
Log.v(
|
||||||
TAG,
|
TAG,
|
||||||
"readResponse: $readOffset/${data.size}, reading $bytesToRead->$bytesToTransfer",
|
"readResponse: $readOffset/${data.size}, reading $bytesToRead->$bytesToTransfer",
|
||||||
@@ -378,7 +378,7 @@ class KcefWebViewProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class WebResponseResourceHandler(
|
private class WebResponseResourceHandler(
|
||||||
val webResponse: WebResourceResponse,
|
val webResponse: WebResourceResponse,
|
||||||
) : ArrayResponseResourceHandler() {
|
) : ArrayResponseResourceHandler() {
|
||||||
override fun processRequest(
|
override fun processRequest(
|
||||||
@@ -408,7 +408,7 @@ class KcefWebViewProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class HtmlResponseResourceHandler(
|
private class HtmlResponseResourceHandler(
|
||||||
val html: String,
|
val html: String,
|
||||||
) : ArrayResponseResourceHandler() {
|
) : ArrayResponseResourceHandler() {
|
||||||
override fun processRequest(
|
override fun processRequest(
|
||||||
@@ -439,7 +439,7 @@ class KcefWebViewProvider(
|
|||||||
view,
|
view,
|
||||||
CefWebResourceRequest(request, frame, false),
|
CefWebResourceRequest(request, frame, false),
|
||||||
)
|
)
|
||||||
Log.v(TAG, "Resource ${request?.url}, result is cancel? $cancel")
|
Log.v(TAG, "Resource ${request.url}, result is cancel? $cancel")
|
||||||
|
|
||||||
handler.post { viewClient.onLoadResource(view, frame?.url) }
|
handler.post { viewClient.onLoadResource(view, frame?.url) }
|
||||||
|
|
||||||
@@ -466,7 +466,7 @@ class KcefWebViewProvider(
|
|||||||
}
|
}
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
// prefer user's response override
|
// prefer user's response override
|
||||||
urlHttpMapping.get(request.url.trimEnd('/'))?.let {
|
urlHttpMapping[request.url.trimEnd('/')]?.let {
|
||||||
return HtmlResponseResourceHandler(it)
|
return HtmlResponseResourceHandler(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,6 +475,7 @@ class KcefWebViewProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
private inner class RequestHandler : CefRequestHandlerAdapter() {
|
private inner class RequestHandler : CefRequestHandlerAdapter() {
|
||||||
override fun getResourceRequestHandler(
|
override fun getResourceRequestHandler(
|
||||||
browser: CefBrowser,
|
browser: CefBrowser,
|
||||||
@@ -484,11 +485,13 @@ class KcefWebViewProvider(
|
|||||||
isDownload: Boolean,
|
isDownload: Boolean,
|
||||||
requestInitiator: String,
|
requestInitiator: String,
|
||||||
disableDefaultHandling: BoolRef,
|
disableDefaultHandling: BoolRef,
|
||||||
): CefResourceRequestHandler? = ResourceRequestHandler()
|
): CefResourceRequestHandler = ResourceRequestHandler()
|
||||||
|
|
||||||
override fun onRenderProcessTerminated(
|
override fun onRenderProcessTerminated(
|
||||||
browser: CefBrowser,
|
browser: CefBrowser,
|
||||||
status: CefRequestHandler.TerminationStatus,
|
status: CefRequestHandler.TerminationStatus,
|
||||||
|
errorCode: Int,
|
||||||
|
errorString: String,
|
||||||
) {
|
) {
|
||||||
handler.post {
|
handler.post {
|
||||||
viewClient.onRenderProcessGone(
|
viewClient.onRenderProcessGone(
|
||||||
@@ -507,13 +510,13 @@ class KcefWebViewProvider(
|
|||||||
override fun onRequestMediaAccessPermission(
|
override fun onRequestMediaAccessPermission(
|
||||||
browser: CefBrowser,
|
browser: CefBrowser,
|
||||||
frame: CefFrame,
|
frame: CefFrame,
|
||||||
requesting_url: String,
|
requestingUrl: String,
|
||||||
requested_permissions: Int,
|
requestedPermissions: Int,
|
||||||
callback: CefMediaAccessCallback,
|
callback: CefMediaAccessCallback,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
handler.post {
|
handler.post {
|
||||||
Log.v(TAG, "Checking permission for $requesting_url: $requested_permissions")
|
Log.v(TAG, "Checking permission for $requestingUrl: $requestedPermissions")
|
||||||
chromeClient.onPermissionRequest(CefPermissionRequest(requesting_url, requested_permissions, callback))
|
chromeClient.onPermissionRequest(CefPermissionRequest(requestingUrl, requestedPermissions, callback))
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -526,7 +529,8 @@ class KcefWebViewProvider(
|
|||||||
Log.v(TAG, "KcefWebViewProvider: initialize")
|
Log.v(TAG, "KcefWebViewProvider: initialize")
|
||||||
destroy()
|
destroy()
|
||||||
kcefClient =
|
kcefClient =
|
||||||
KCEF.newClientBlocking().apply {
|
runBlocking {
|
||||||
|
CefHelper.createClient().apply {
|
||||||
addDisplayHandler(DisplayHandler())
|
addDisplayHandler(DisplayHandler())
|
||||||
addLoadHandler(LoadHandler())
|
addLoadHandler(LoadHandler())
|
||||||
addRequestHandler(RequestHandler())
|
addRequestHandler(RequestHandler())
|
||||||
@@ -537,6 +541,7 @@ class KcefWebViewProvider(
|
|||||||
config.jsCancelFunction = QUERY_CANCEL_FN
|
config.jsCancelFunction = QUERY_CANCEL_FN
|
||||||
addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler()))
|
addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler()))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
initHandler.init(this)
|
initHandler.init(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -613,6 +618,7 @@ class KcefWebViewProvider(
|
|||||||
.createBrowser(
|
.createBrowser(
|
||||||
loadUrl,
|
loadUrl,
|
||||||
CefRendering.OFFSCREEN,
|
CefRendering.OFFSCREEN,
|
||||||
|
false,
|
||||||
).apply {
|
).apply {
|
||||||
// NOTE: Without this, we don't seem to be receiving any events
|
// NOTE: Without this, we don't seem to be receiving any events
|
||||||
createImmediately()
|
createImmediately()
|
||||||
@@ -637,6 +643,7 @@ class KcefWebViewProvider(
|
|||||||
.createBrowser(
|
.createBrowser(
|
||||||
url,
|
url,
|
||||||
CefRendering.OFFSCREEN,
|
CefRendering.OFFSCREEN,
|
||||||
|
false,
|
||||||
).apply {
|
).apply {
|
||||||
// NOTE: Without this, we don't seem to be receiving any events
|
// NOTE: Without this, we don't seem to be receiving any events
|
||||||
createImmediately()
|
createImmediately()
|
||||||
@@ -662,23 +669,15 @@ class KcefWebViewProvider(
|
|||||||
browser?.close(true)
|
browser?.close(true)
|
||||||
browser?.dispose()
|
browser?.dispose()
|
||||||
chromeClient.onProgressChanged(view, 0)
|
chromeClient.onProgressChanged(view, 0)
|
||||||
|
val url = baseUrl ?: "about:blank"
|
||||||
|
urlHttpMapping[url.trimEnd('/')] = data
|
||||||
|
|
||||||
browser =
|
browser =
|
||||||
(
|
kcefClient!!
|
||||||
baseUrl?.let { url ->
|
.createBrowser(
|
||||||
urlHttpMapping.put(url.trimEnd('/'), data)
|
|
||||||
kcefClient!!.createBrowser(
|
|
||||||
url,
|
url,
|
||||||
CefRendering.OFFSCREEN,
|
CefRendering.OFFSCREEN,
|
||||||
)
|
false,
|
||||||
}
|
|
||||||
?: run {
|
|
||||||
kcefClient!!.createBrowserWithHtml(
|
|
||||||
data,
|
|
||||||
KCEFBrowser.BLANK_URI,
|
|
||||||
CefRendering.OFFSCREEN,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
).apply {
|
).apply {
|
||||||
// NOTE: Without this, we don't seem to be receiving any events
|
// NOTE: Without this, we don't seem to be receiving any events
|
||||||
createImmediately()
|
createImmediately()
|
||||||
@@ -692,11 +691,11 @@ class KcefWebViewProvider(
|
|||||||
) {
|
) {
|
||||||
browser!!.evaluateJavaScript(
|
browser!!.evaluateJavaScript(
|
||||||
script.removePrefix("javascript:"),
|
script.removePrefix("javascript:"),
|
||||||
|
)
|
||||||
{
|
{
|
||||||
Log.v(TAG, "JS returned: $it")
|
Log.v(TAG, "JS returned: $it")
|
||||||
it?.let { handler.post { resultCallback?.onReceiveValue(it) } }
|
it?.let { handler.post { resultCallback?.onReceiveValue(it) } }
|
||||||
},
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun saveWebArchive(filename: String): Unit = throw RuntimeException("Stub!")
|
override fun saveWebArchive(filename: String): Unit = throw RuntimeException("Stub!")
|
||||||
@@ -838,6 +837,7 @@ class KcefWebViewProvider(
|
|||||||
|
|
||||||
override fun getWebChromeClient(): WebChromeClient = chromeClient
|
override fun getWebChromeClient(): WebChromeClient = chromeClient
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
override fun setPictureListener(listener: PictureListener): Unit = throw RuntimeException("Stub!")
|
override fun setPictureListener(listener: PictureListener): Unit = throw RuntimeException("Stub!")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -860,7 +860,7 @@ class KcefWebViewProvider(
|
|||||||
obj: Any,
|
obj: Any,
|
||||||
interfaceName: String,
|
interfaceName: String,
|
||||||
) {
|
) {
|
||||||
val cls = obj::class as KClass<Any>
|
val cls = obj::class
|
||||||
mappings.addAll(
|
mappings.addAll(
|
||||||
cls.declaredMemberFunctions.map {
|
cls.declaredMemberFunctions.map {
|
||||||
// This is ridiculous, but necessary, otherwise "public final" throws
|
// This is ridiculous, but necessary, otherwise "public final" throws
|
||||||
@@ -922,7 +922,8 @@ class KcefWebViewProvider(
|
|||||||
override fun getRendererPriorityWaivedWhenNotVisible(): Boolean = throw RuntimeException("Stub!")
|
override fun getRendererPriorityWaivedWhenNotVisible(): Boolean = throw RuntimeException("Stub!")
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
override fun setTextClassifier(textClassifier: TextClassifier?) {}
|
override fun setTextClassifier(textClassifier: TextClassifier?) {
|
||||||
|
}
|
||||||
|
|
||||||
override fun getTextClassifier(): TextClassifier = TextClassifier.NO_OP
|
override fun getTextClassifier(): TextClassifier = TextClassifier.NO_OP
|
||||||
|
|
||||||
@@ -948,11 +949,13 @@ class KcefWebViewProvider(
|
|||||||
override fun onProvideAutofillVirtualStructure(
|
override fun onProvideAutofillVirtualStructure(
|
||||||
@SuppressWarnings("unused") structure: android.view.ViewStructure,
|
@SuppressWarnings("unused") structure: android.view.ViewStructure,
|
||||||
@SuppressWarnings("unused") flags: Int,
|
@SuppressWarnings("unused") flags: Int,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
override fun autofill(
|
override fun autofill(
|
||||||
@SuppressWarnings("unused") values: SparseArray<AutofillValue>,
|
@SuppressWarnings("unused") values: SparseArray<AutofillValue>,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
override fun isVisibleToUserForAutofill(
|
override fun isVisibleToUserForAutofill(
|
||||||
@SuppressWarnings("unused") virtualId: Int,
|
@SuppressWarnings("unused") virtualId: Int,
|
||||||
@@ -963,7 +966,8 @@ class KcefWebViewProvider(
|
|||||||
override fun onProvideContentCaptureStructure(
|
override fun onProvideContentCaptureStructure(
|
||||||
@SuppressWarnings("unused") structure: android.view.ViewStructure,
|
@SuppressWarnings("unused") structure: android.view.ViewStructure,
|
||||||
@SuppressWarnings("unused") flags: Int,
|
@SuppressWarnings("unused") flags: Int,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
override fun getAccessibilityNodeProvider(): AccessibilityNodeProvider = throw RuntimeException("Stub!")
|
override fun getAccessibilityNodeProvider(): AccessibilityNodeProvider = throw RuntimeException("Stub!")
|
||||||
|
|
||||||
@@ -1033,7 +1037,8 @@ class KcefWebViewProvider(
|
|||||||
override fun onMovedToDisplay(
|
override fun onMovedToDisplay(
|
||||||
displayId: Int,
|
displayId: Int,
|
||||||
config: Configuration,
|
config: Configuration,
|
||||||
) {}
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
override fun onVisibilityChanged(
|
override fun onVisibilityChanged(
|
||||||
changedView: View,
|
changedView: View,
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
- .
|
- .
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- (Database/H2) Use the latest H2 database engine
|
- (**Database/H2**) Use the latest H2 database engine
|
||||||
- (Startup) Crash on startup if an unrecoverable error happens
|
- (**Startup**) Crash on startup if an unrecoverable error happens
|
||||||
|
- (**WebView**) Use JCEF directly and update to newest Chromium
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- (**CloudFlareInterceptor**) Don't send the `cf_clearance` cookie back to Flaresolverr
|
- (**CloudFlareInterceptor**) Don't send the `cf_clearance` cookie back to Flaresolverr
|
||||||
|
|||||||
@@ -106,13 +106,13 @@ Download the latest `linux-x64`(x86_64) release from [the releases section](http
|
|||||||
|
|
||||||
#### WebView support (GNU/Linux)
|
#### WebView support (GNU/Linux)
|
||||||
|
|
||||||
WebView support is implemented via [KCEF](https://github.com/DATL4G/KCEF).
|
WebView support is implemented via [JCEF](https://github.com/JetBrains/jcef).
|
||||||
This is optional, and is only necessary to support some extensions.
|
This is optional, and is only necessary to support some extensions.
|
||||||
|
|
||||||
To have a functional WebView, several dependencies are required; aside from X11 libraries necessary for rendering Chromium, some JNI bindings are necessary: gluegen and jogl (found in Ubuntu as `libgluegen2-jni` and `libjogl2-jni`).
|
To have a functional WebView, several dependencies are required; aside from X11 libraries necessary for rendering Chromium, some JNI bindings are necessary: gluegen and jogl (found in Ubuntu as `libgluegen2-jni` and `libjogl2-jni`).
|
||||||
Note that on some systems (e.g. Ubuntu), the JNI libraries are not automatically found, see below.
|
Note that on some systems (e.g. Ubuntu), the JNI libraries are not automatically found, see below.
|
||||||
|
|
||||||
A KCEF server is launched on startup, which loads the X11 libraries.
|
A CEF server is launched on startup, which loads the X11 libraries.
|
||||||
If those are missing, you should see "Could not load 'jcef' library".
|
If those are missing, you should see "Could not load 'jcef' library".
|
||||||
If so, use `ldd ~/.local/share/Tachidesk/bin/kcef/libjcef.so | grep not` to figure out which libraries are not found on your system.
|
If so, use `ldd ~/.local/share/Tachidesk/bin/kcef/libjcef.so | grep not` to figure out which libraries are not found on your system.
|
||||||
|
|
||||||
@@ -123,6 +123,10 @@ This search path includes the current working directory, if you do not want to m
|
|||||||
|
|
||||||
Refer to the [Dockerfile](https://github.com/Suwayomi/Suwayomi-Server-docker/blob/main/Dockerfile) for more details.
|
Refer to the [Dockerfile](https://github.com/Suwayomi/Suwayomi-Server-docker/blob/main/Dockerfile) for more details.
|
||||||
|
|
||||||
|
Note that it is required to have an X session active and available to Suwayomi (i.e. `DISPLAY` is set).
|
||||||
|
It is not enough to have `WAYLAND_DISPLAY`, if your environment does not provide xwayland (or if you run Suwayomi as a service), you need to use a tool like [`Xvfb`](https://en.wikipedia.org/wiki/Xvfb).
|
||||||
|
The Dockerfile linked above also does this.
|
||||||
|
|
||||||
## Other methods of getting Suwayomi
|
## Other methods of getting Suwayomi
|
||||||
### Docker
|
### Docker
|
||||||
Check our Official Docker release [Suwayomi Container](https://github.com/orgs/Suwayomi/packages/container/package/tachidesk) for running Suwayomi Server in a docker container. Source code for our container is available at [docker-tachidesk](https://github.com/Suwayomi/docker-tachidesk), an example compose file can also be found there. By default, the server will be running on http://localhost:4567 open this url in your browser.
|
Check our Official Docker release [Suwayomi Container](https://github.com/orgs/Suwayomi/packages/container/package/tachidesk) for running Suwayomi Server in a docker container. Source code for our container is available at [docker-tachidesk](https://github.com/Suwayomi/docker-tachidesk), an example compose file can also be found there. By default, the server will be running on http://localhost:4567 open this url in your browser.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ allprojects {
|
|||||||
maven("https://github.com/Suwayomi/Suwayomi-Server/raw/android-jar/")
|
maven("https://github.com/Suwayomi/Suwayomi-Server/raw/android-jar/")
|
||||||
maven("https://jitpack.io")
|
maven("https://jitpack.io")
|
||||||
maven("https://jogamp.org/deployment/maven")
|
maven("https://jogamp.org/deployment/maven")
|
||||||
|
maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ val getTachideskVersion = { "v2.2.${getCommitCount()}" }
|
|||||||
|
|
||||||
val webUIRevisionTag = "r3136"
|
val webUIRevisionTag = "r3136"
|
||||||
|
|
||||||
|
val webviewJbrRelease = "jbr-release-25.0.3b475.60"
|
||||||
|
|
||||||
private val getCommitCount = {
|
private val getCommitCount = {
|
||||||
runCatching {
|
runCatching {
|
||||||
ProcessBuilder()
|
ProcessBuilder()
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ server.webUISubpath = ""
|
|||||||
- `server.webUIUpdateCheckInterval` the interval time in hours at which to check for updates. Use `0` to disable update checking.
|
- `server.webUIUpdateCheckInterval` the interval time in hours at which to check for updates. Use `0` to disable update checking.
|
||||||
- `server.webUISubpath` controls on which sub-path the UI is served; by default, it will be accessible on `/` (i.e. directly), with this setting it can also be set to appear at e.g. `/suwayomi`
|
- `server.webUISubpath` controls on which sub-path the UI is served; by default, it will be accessible on `/` (i.e. directly), with this setting it can also be set to appear at e.g. `/suwayomi`
|
||||||
|
|
||||||
|
|
||||||
|
### webView
|
||||||
|
```
|
||||||
|
server.kcefEnabled = true
|
||||||
|
```
|
||||||
|
- `server.kcefEnabled` controls if KCEF WebView provider is enabled.
|
||||||
|
|
||||||
|
|
||||||
### Downloader
|
### Downloader
|
||||||
```
|
```
|
||||||
server.downloadAsCbz = true
|
server.downloadAsCbz = true
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ xmlserialization = "0.91.3"
|
|||||||
ktlint = "1.8.0"
|
ktlint = "1.8.0"
|
||||||
koin = "4.2.1"
|
koin = "4.2.1"
|
||||||
moko = "0.26.4"
|
moko = "0.26.4"
|
||||||
|
jcef = "144.0.15-g72717cf-chromium-144.0.7559.172-api-1.21-262-b34"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# Kotlin
|
# Kotlin
|
||||||
@@ -156,7 +157,9 @@ cron4j = "it.sauronsoftware.cron4j:cron4j:2.2.5"
|
|||||||
cronUtils = "com.cronutils:cron-utils:9.2.1"
|
cronUtils = "com.cronutils:cron-utils:9.2.1"
|
||||||
|
|
||||||
# Webview
|
# Webview
|
||||||
kcef = "dev.datlag:kcef:2024.04.20.4"
|
jcef = { module = "org.jetbrains.intellij.deps.jcef:jcef", version.ref = "jcef" }
|
||||||
|
gluegen = "org.jogamp.gluegen:gluegen-rt:2.5.0"
|
||||||
|
jogl = "org.jogamp.jogl:jogl-all:2.5.0"
|
||||||
|
|
||||||
# User
|
# User
|
||||||
jwt = "com.auth0:java-jwt:4.5.2"
|
jwt = "com.auth0:java-jwt:4.5.2"
|
||||||
@@ -213,7 +216,7 @@ shared = [
|
|||||||
"dex2jar-tools",
|
"dex2jar-tools",
|
||||||
"apk-parser",
|
"apk-parser",
|
||||||
"jackson-annotations",
|
"jackson-annotations",
|
||||||
"kcef"
|
"jcef",
|
||||||
]
|
]
|
||||||
|
|
||||||
sharedTest = [
|
sharedTest = [
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ dependencies {
|
|||||||
implementation(libs.bundles.shared)
|
implementation(libs.bundles.shared)
|
||||||
testImplementation(libs.bundles.sharedTest)
|
testImplementation(libs.bundles.sharedTest)
|
||||||
|
|
||||||
|
// WebView
|
||||||
|
implementation(libs.gluegen)
|
||||||
|
implementation(libs.jogl)
|
||||||
|
|
||||||
// OkHttp
|
// OkHttp
|
||||||
implementation(libs.bundles.okhttp)
|
implementation(libs.bundles.okhttp)
|
||||||
implementation(libs.okio)
|
implementation(libs.okio)
|
||||||
@@ -159,6 +163,8 @@ buildConfig {
|
|||||||
|
|
||||||
buildConfigField("String", "GITHUB", quoteWrap("https://github.com/Suwayomi/Suwayomi-Server"))
|
buildConfigField("String", "GITHUB", quoteWrap("https://github.com/Suwayomi/Suwayomi-Server"))
|
||||||
buildConfigField("String", "DISCORD", quoteWrap("https://discord.gg/DDZdqZWaHA"))
|
buildConfigField("String", "DISCORD", quoteWrap("https://discord.gg/DDZdqZWaHA"))
|
||||||
|
buildConfigField("String", "JCEF_VERSION", quoteWrap(libs.versions.jcef.get()))
|
||||||
|
buildConfigField("String", "JCEF_JBR_RELEASE", quoteWrap(webviewJbrRelease))
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks {
|
tasks {
|
||||||
@@ -172,6 +178,7 @@ tasks {
|
|||||||
"Specification-Version" to getTachideskVersion(),
|
"Specification-Version" to getTachideskVersion(),
|
||||||
"Implementation-Version" to getTachideskRevision(),
|
"Implementation-Version" to getTachideskRevision(),
|
||||||
"Multi-Release" to true, // needed for polyglot
|
"Multi-Release" to true, // needed for polyglot
|
||||||
|
"X-JBR-Release" to webviewJbrRelease,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
archiveBaseName.set(rootProject.name)
|
archiveBaseName.set(rootProject.name)
|
||||||
@@ -182,7 +189,11 @@ tasks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test {
|
test {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform {
|
||||||
|
if (!project.hasProperty("masstest")) {
|
||||||
|
exclude("**/masstest/*")
|
||||||
|
}
|
||||||
|
}
|
||||||
testLogging {
|
testLogging {
|
||||||
showStandardStreams = true
|
showStandardStreams = true
|
||||||
events("passed", "skipped", "failed")
|
events("passed", "skipped", "failed")
|
||||||
|
|||||||
@@ -1014,6 +1014,14 @@ class ServerConfig(
|
|||||||
description = "Use Hikari Connection Pool to connect to the database.",
|
description = "Use Hikari Connection Pool to connect to the database.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val kcefEnabled: MutableStateFlow<Boolean> by BooleanSetting(
|
||||||
|
protoNumber = 86,
|
||||||
|
group = SettingGroup.WEB_VIEW,
|
||||||
|
privacySafe = true,
|
||||||
|
defaultValue = true,
|
||||||
|
description = "Enable the WebView via CEF (Chromium)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** ****************************************************************** **/
|
/** ****************************************************************** **/
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ enum class SettingGroup(
|
|||||||
CLOUDFLARE("Cloudflare"),
|
CLOUDFLARE("Cloudflare"),
|
||||||
OPDS("OPDS"),
|
OPDS("OPDS"),
|
||||||
KOREADER_SYNC("KOReader sync"),
|
KOREADER_SYNC("KOReader sync"),
|
||||||
|
WEB_VIEW("WebView"),
|
||||||
;
|
;
|
||||||
|
|
||||||
override fun toString(): String = value
|
override fun toString(): String = value
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
package suwayomi.tachidesk.global.impl
|
package suwayomi.tachidesk.global.impl
|
||||||
|
|
||||||
import dev.datlag.kcef.KCEF
|
|
||||||
import dev.datlag.kcef.KCEFBrowser
|
|
||||||
import dev.datlag.kcef.KCEFClient
|
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.Cookie
|
import okhttp3.Cookie
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
|
import org.cef.CefClient
|
||||||
import org.cef.CefSettings
|
import org.cef.CefSettings
|
||||||
import org.cef.browser.CefBrowser
|
import org.cef.browser.CefBrowser
|
||||||
import org.cef.browser.CefFrame
|
import org.cef.browser.CefFrame
|
||||||
@@ -26,6 +25,9 @@ import org.cef.network.CefCookie
|
|||||||
import org.cef.network.CefCookieManager
|
import org.cef.network.CefCookieManager
|
||||||
import org.cef.network.CefRequest
|
import org.cef.network.CefRequest
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import xyz.nulldev.androidcompat.webkit.CefHelper
|
||||||
|
import xyz.nulldev.androidcompat.webkit.dispose
|
||||||
|
import xyz.nulldev.androidcompat.webkit.evaluateJavaScript
|
||||||
import java.awt.Component
|
import java.awt.Component
|
||||||
import java.awt.HeadlessException
|
import java.awt.HeadlessException
|
||||||
import java.awt.Rectangle
|
import java.awt.Rectangle
|
||||||
@@ -47,8 +49,8 @@ import javax.swing.JPanel
|
|||||||
class KcefWebView {
|
class KcefWebView {
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
private val renderHandler = RenderHandler()
|
private val renderHandler = RenderHandler()
|
||||||
private var kcefClient: KCEFClient? = null
|
private var kcefClient: CefClient? = null
|
||||||
private var browser: KCEFBrowser? = null
|
private var browser: CefBrowser? = null
|
||||||
private var width = 1000
|
private var width = 1000
|
||||||
private var height = 1000
|
private var height = 1000
|
||||||
|
|
||||||
@@ -76,7 +78,8 @@ class KcefWebView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable sealed class Event
|
@Serializable
|
||||||
|
sealed class Event
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("consoleMessage")
|
@SerialName("consoleMessage")
|
||||||
@@ -247,11 +250,13 @@ class KcefWebView {
|
|||||||
init {
|
init {
|
||||||
destroy()
|
destroy()
|
||||||
kcefClient =
|
kcefClient =
|
||||||
KCEF.newClientBlocking().apply {
|
runBlocking {
|
||||||
|
CefHelper.createClient().apply {
|
||||||
addDisplayHandler(DisplayHandler())
|
addDisplayHandler(DisplayHandler())
|
||||||
addLoadHandler(LoadHandler())
|
addLoadHandler(LoadHandler())
|
||||||
addRequestHandler(RequestHandler())
|
addRequestHandler(RequestHandler())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug { "Start loading cookies" }
|
logger.debug { "Start loading cookies" }
|
||||||
CefCookieManager.getGlobalManager().apply {
|
CefCookieManager.getGlobalManager().apply {
|
||||||
@@ -289,6 +294,7 @@ class KcefWebView {
|
|||||||
.createBrowser(
|
.createBrowser(
|
||||||
url,
|
url,
|
||||||
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
|
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
|
||||||
|
false,
|
||||||
// NOTE: with a context, we don't seem to be getting any cookies
|
// NOTE: with a context, we don't seem to be getting any cookies
|
||||||
).apply {
|
).apply {
|
||||||
// NOTE: Without this, we don't seem to be receiving any events
|
// NOTE: Without this, we don't seem to be receiving any events
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ import com.typesafe.config.ConfigException
|
|||||||
import com.typesafe.config.ConfigRenderOptions
|
import com.typesafe.config.ConfigRenderOptions
|
||||||
import com.typesafe.config.ConfigValue
|
import com.typesafe.config.ConfigValue
|
||||||
import com.typesafe.config.parser.ConfigDocument
|
import com.typesafe.config.parser.ConfigDocument
|
||||||
import dev.datlag.kcef.KCEF
|
|
||||||
import dev.datlag.kcef.KCEFBuilder.Settings.LogSeverity
|
|
||||||
import eu.kanade.tachiyomi.App
|
import eu.kanade.tachiyomi.App
|
||||||
import eu.kanade.tachiyomi.createAppModule
|
import eu.kanade.tachiyomi.createAppModule
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
@@ -49,6 +47,7 @@ import suwayomi.tachidesk.server.database.databaseUp
|
|||||||
import suwayomi.tachidesk.server.generated.BuildConfig
|
import suwayomi.tachidesk.server.generated.BuildConfig
|
||||||
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
import suwayomi.tachidesk.server.settings.SettingsRegistry
|
||||||
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
|
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
|
||||||
|
import suwayomi.tachidesk.server.util.CEFManager
|
||||||
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
|
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
|
||||||
import suwayomi.tachidesk.server.util.ExitCode
|
import suwayomi.tachidesk.server.util.ExitCode
|
||||||
import suwayomi.tachidesk.server.util.SystemTray
|
import suwayomi.tachidesk.server.util.SystemTray
|
||||||
@@ -518,56 +517,8 @@ fun applicationSetup() {
|
|||||||
// start DownloadManager and restore + resume downloads
|
// start DownloadManager and restore + resume downloads
|
||||||
DownloadManager.restoreAndResumeDownloads()
|
DownloadManager.restoreAndResumeDownloads()
|
||||||
|
|
||||||
|
// asynchronously initialize CEF
|
||||||
GlobalScope.launch {
|
GlobalScope.launch {
|
||||||
val logger = KotlinLogging.logger("KCEF")
|
CEFManager.init()
|
||||||
KCEF.init(
|
|
||||||
builder = {
|
|
||||||
progress {
|
|
||||||
var lastNum = -1
|
|
||||||
onDownloading {
|
|
||||||
val num = it.roundToInt()
|
|
||||||
if (num > lastNum) {
|
|
||||||
lastNum = num
|
|
||||||
logger.info { "KCEF download progress: $num%" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
download { github { release("jbr-release-21.0.10b1163.108") } }
|
|
||||||
settings {
|
|
||||||
windowlessRenderingEnabled = true
|
|
||||||
cachePath = (Path(applicationDirs.dataRoot) / "cache/kcef").toString()
|
|
||||||
logSeverity = if (serverConfig.debugLogsEnabled.value) LogSeverity.Verbose else LogSeverity.Default
|
|
||||||
}
|
|
||||||
appHandler(
|
|
||||||
KCEF.AppHandler(
|
|
||||||
arrayOf(
|
|
||||||
"--disable-gpu",
|
|
||||||
// #1486 needed to be able to render without a window
|
|
||||||
"--off-screen-rendering-enabled",
|
|
||||||
// #1489 since /dev/shm is restricted in docker (OOM)
|
|
||||||
"--disable-dev-shm-usage",
|
|
||||||
// #1723 support Widevine (incomplete)
|
|
||||||
"--enable-widevine-cdm",
|
|
||||||
// #1736 JCEF does implement stack guards properly
|
|
||||||
"--change-stack-guard-on-fork=disable",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val kcefDir = Path(applicationDirs.dataRoot) / "bin/kcef"
|
|
||||||
kcefDir.createDirectories()
|
|
||||||
installDir(kcefDir.toFile())
|
|
||||||
},
|
|
||||||
onError = { it?.printStackTrace() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Runtime.getRuntime().addShutdownHook(
|
|
||||||
thread(start = false) {
|
|
||||||
val logger = KotlinLogging.logger("KCEF")
|
|
||||||
logger.debug { "Shutting down KCEF" }
|
|
||||||
KCEF.disposeBlocking()
|
|
||||||
logger.debug { "KCEF shutdown complete" }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,573 @@
|
|||||||
|
package suwayomi.tachidesk.server.util
|
||||||
|
|
||||||
|
import android.text.format.Formatter
|
||||||
|
import com.jetbrains.cef.JCefAppConfig
|
||||||
|
import eu.kanade.tachiyomi.network.ProgressListener
|
||||||
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
|
import eu.kanade.tachiyomi.network.newCachelessCallWithProgress
|
||||||
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.subscribe
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||||
|
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
|
||||||
|
import org.cef.CefApp
|
||||||
|
import org.cef.CefSettings.LogSeverity
|
||||||
|
import org.cef.SystemBootstrap
|
||||||
|
import suwayomi.tachidesk.server.ApplicationDirs
|
||||||
|
import suwayomi.tachidesk.server.generated.BuildConfig
|
||||||
|
import suwayomi.tachidesk.server.serverConfig
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import xyz.nulldev.androidcompat.webkit.CefHelper
|
||||||
|
import java.io.BufferedOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.LinkOption
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.StandardOpenOption
|
||||||
|
import java.nio.file.attribute.PosixFilePermission
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
import kotlin.io.path.ExperimentalPathApi
|
||||||
|
import kotlin.io.path.Path
|
||||||
|
import kotlin.io.path.absolute
|
||||||
|
import kotlin.io.path.absolutePathString
|
||||||
|
import kotlin.io.path.createDirectories
|
||||||
|
import kotlin.io.path.createTempDirectory
|
||||||
|
import kotlin.io.path.deleteExisting
|
||||||
|
import kotlin.io.path.deleteIfExists
|
||||||
|
import kotlin.io.path.deleteRecursively
|
||||||
|
import kotlin.io.path.div
|
||||||
|
import kotlin.io.path.exists
|
||||||
|
import kotlin.io.path.getPosixFilePermissions
|
||||||
|
import kotlin.io.path.inputStream
|
||||||
|
import kotlin.io.path.isRegularFile
|
||||||
|
import kotlin.io.path.isSameFileAs
|
||||||
|
import kotlin.io.path.isSymbolicLink
|
||||||
|
import kotlin.io.path.listDirectoryEntries
|
||||||
|
import kotlin.io.path.moveTo
|
||||||
|
import kotlin.io.path.outputStream
|
||||||
|
import kotlin.io.path.readLines
|
||||||
|
import kotlin.io.path.readSymbolicLink
|
||||||
|
import kotlin.io.path.readText
|
||||||
|
import kotlin.io.path.setPosixFilePermissions
|
||||||
|
import kotlin.io.path.writeText
|
||||||
|
import kotlin.streams.asSequence
|
||||||
|
|
||||||
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPathApi::class)
|
||||||
|
object CEFManager {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + Dispatchers.IO)
|
||||||
|
private val applicationDirs by lazy { Injekt.get<ApplicationDirs>() }
|
||||||
|
private val cefDir by lazy { Path(applicationDirs.dataRoot) / "bin/kcef" }
|
||||||
|
private val releaseFile by lazy { cefDir / "release" }
|
||||||
|
|
||||||
|
fun init() =
|
||||||
|
scope.launch {
|
||||||
|
serverConfig.subscribeTo(serverConfig.kcefEnabled, CEFManager::initAsync, ignoreInitialValue = false)
|
||||||
|
|
||||||
|
Runtime.getRuntime().addShutdownHook(
|
||||||
|
thread(start = false) {
|
||||||
|
CefHelper.cefApp.value.getOrNull()?.let {
|
||||||
|
logger.debug { "Shutting down CEF" }
|
||||||
|
it.dispose()
|
||||||
|
logger.debug { "CEF shutdown complete" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun initAsync(): Unit =
|
||||||
|
try {
|
||||||
|
CefHelper.cefApp.value = Result.success(null)
|
||||||
|
|
||||||
|
if (!serverConfig.kcefEnabled.value) {
|
||||||
|
throw CefException("CEF is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
System.loadLibrary("jawt")
|
||||||
|
|
||||||
|
if (serverConfig.debugLogsEnabled.value) System.setProperty("jcef.log.verbose", "true")
|
||||||
|
|
||||||
|
if (!isInstallationValid(releaseFile)) {
|
||||||
|
downloadRelease(cefDir)
|
||||||
|
|
||||||
|
if (!isInstallationValid(releaseFile)) {
|
||||||
|
throw CefException("Failed to provide a valid installation, this is a bug!")
|
||||||
|
}
|
||||||
|
logger.info { "Downloaded CEF successfully!" }
|
||||||
|
}
|
||||||
|
|
||||||
|
val app =
|
||||||
|
if (CefApp.getInstanceIfAny() == null) {
|
||||||
|
val config =
|
||||||
|
JCefAppConfig.getInstance(cefDir.toString(), false).apply {
|
||||||
|
appArgsAsList.addAll(
|
||||||
|
arrayOf(
|
||||||
|
"--disable-gpu",
|
||||||
|
// #1486 needed to be able to render without a window
|
||||||
|
"--off-screen-rendering-enabled",
|
||||||
|
// #1489 since /dev/shm is restricted in docker (OOM)
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
// #1723 support Widevine (incomplete)
|
||||||
|
"--enable-widevine-cdm",
|
||||||
|
// #1736 JCEF does implement stack guards properly
|
||||||
|
"--change-stack-guard-on-fork=disable",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cefSettings.apply {
|
||||||
|
windowless_rendering_enabled = true
|
||||||
|
cache_path = (Path(applicationDirs.dataRoot) / "cache/kcef").absolutePathString()
|
||||||
|
log_severity =
|
||||||
|
if (serverConfig.debugLogsEnabled.value) {
|
||||||
|
LogSeverity.LOGSEVERITY_VERBOSE
|
||||||
|
} else {
|
||||||
|
LogSeverity.LOGSEVERITY_DEFAULT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.debug {
|
||||||
|
"Attempting to initialize CEF: exe=${config.getServerExe()}, settings={${
|
||||||
|
config.cefSettings.getDescription()
|
||||||
|
}}, args=${config.getAppArgs().contentToString()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is essentially https://github.com/JetBrains/jcef/blob/5b93e5b916068316f1c8e7f8a59bf958d5ffd6e1/java/org/cef/CefApp.java#L777
|
||||||
|
// we do this here because JCEF has no mechanism to tell us that initalization failed, they just record in an inaccessible future
|
||||||
|
val os = Platform.current.os
|
||||||
|
when {
|
||||||
|
os.isLinux -> {
|
||||||
|
config.getLoader().loadLibrary("cef")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.isWindows -> {
|
||||||
|
config.getLoader().loadLibrary("chrome_elf")
|
||||||
|
config.getLoader().loadLibrary("libcef")
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
config.getLoader().loadLibrary("jcef")
|
||||||
|
|
||||||
|
CefApp.setIsRemoteEnabled(config.isRemoteEnabled)
|
||||||
|
SystemBootstrap.setLoader(config.getLoader())
|
||||||
|
CefApp.startup(config.getAppArgs())
|
||||||
|
|
||||||
|
CefApp.getInstance(config.getAppArgs(), config.cefSettings, config.getServerExe())
|
||||||
|
} else {
|
||||||
|
logger.debug { "Getting existing app instance" }
|
||||||
|
CefApp.getInstance()
|
||||||
|
}
|
||||||
|
CefHelper.cefApp.value = Result.success(app)
|
||||||
|
logger.debug { "CEF app created" }
|
||||||
|
|
||||||
|
CefHelper.waitForInit().first()
|
||||||
|
return
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
logger.error(e) { "Failed to set up CEF" }
|
||||||
|
CefHelper.cefApp.value = Result.failure(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isInstallationValid(releaseFile: Path): Boolean {
|
||||||
|
if (!releaseFile.exists() || !releaseFile.isRegularFile()) return false
|
||||||
|
return try {
|
||||||
|
releaseFile
|
||||||
|
.readLines()
|
||||||
|
.firstNotNullOfOrNull {
|
||||||
|
if (it.contains("JCEF_VERSION_DETAILED")) it.split("=").getOrNull(1) else null
|
||||||
|
}?.let {
|
||||||
|
logger.debug { "Comparing internal ${BuildConfig.JCEF_VERSION} against downloaded $it" }
|
||||||
|
BuildConfig.JCEF_VERSION.split("-chromium")[0] == it.split("-chromium")[0]
|
||||||
|
} ?: false
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun downloadRelease(installDir: Path) {
|
||||||
|
logger.info { "Downloading CEF from Github (${BuildConfig.JCEF_JBR_RELEASE})" }
|
||||||
|
installDir.deleteRecursively()
|
||||||
|
|
||||||
|
if (!runCatching { installDir.createDirectories() }.isSuccess) {
|
||||||
|
throw CefException("Failed to create installation directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
val client = OkHttpClient.Builder().followRedirects(true).build()
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url("https://api.github.com/repos/JetBrains/JetBrainsRuntime/releases/tags/${BuildConfig.JCEF_JBR_RELEASE}")
|
||||||
|
.addHeader("Content-Type", GithubReleaseTransform.GITHUB_JSON)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val downloadUrl =
|
||||||
|
client.newCall(request).awaitSuccess().use { response ->
|
||||||
|
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
||||||
|
GithubReleaseTransform.transform(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
val tempDownload = createTempDirectory("cef")
|
||||||
|
try {
|
||||||
|
val downFile = tempDownload / "download.tar.gz"
|
||||||
|
val downloadRequest =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(downloadUrl)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
downFile.outputStream().use { output ->
|
||||||
|
client
|
||||||
|
.newCachelessCallWithProgress(
|
||||||
|
downloadRequest,
|
||||||
|
object : ProgressListener {
|
||||||
|
private var lastPercent = 0L
|
||||||
|
|
||||||
|
override fun update(
|
||||||
|
bytesRead: Long,
|
||||||
|
contentLength: Long,
|
||||||
|
done: Boolean,
|
||||||
|
) {
|
||||||
|
val newPercent = (bytesRead * 100).floorDiv(contentLength)
|
||||||
|
if (newPercent != lastPercent) {
|
||||||
|
logger.info { "Downloading $newPercent% of ${Formatter.formatFileSize(null, contentLength)}" }
|
||||||
|
lastPercent = newPercent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
).awaitSuccess()
|
||||||
|
.use { response ->
|
||||||
|
response.body.byteStream().use { input -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug { "Extracting CEF..." }
|
||||||
|
TarGzExtractor.extract(
|
||||||
|
installDir,
|
||||||
|
downFile,
|
||||||
|
4096,
|
||||||
|
)
|
||||||
|
TarGzExtractor.move(
|
||||||
|
installDir,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
tempDownload.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CefException(
|
||||||
|
msg: String,
|
||||||
|
) : Exception(msg)
|
||||||
|
|
||||||
|
// based on https://github.com/DatL4g/KCEF/blob/master/kcef/src/main/kotlin/dev/datlag/kcef/KCEFBuilder.kt
|
||||||
|
private object GithubReleaseTransform {
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
private val urlRegex = "(https?://|www.)[-a-zA-Z0-9+&@#/%?=~_|!:.;]*[-a-zA-Z0-9+&@#/%=~_|]".toRegex()
|
||||||
|
const val GITHUB_JSON = "application/vnd.github+json"
|
||||||
|
|
||||||
|
fun transform(initialResponse: Response): String {
|
||||||
|
val release = with(json) { initialResponse.parseAs<GitHubRelease>() }
|
||||||
|
|
||||||
|
val packageUrlList =
|
||||||
|
urlRegex
|
||||||
|
.findAll(release.body)
|
||||||
|
.toList()
|
||||||
|
.map { it.value }
|
||||||
|
.filterNot {
|
||||||
|
it.isBlank() || it.endsWith(".checksum", true)
|
||||||
|
}.filter {
|
||||||
|
it.contains("jcef", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val platform = Platform.current
|
||||||
|
val osPackageList =
|
||||||
|
packageUrlList
|
||||||
|
.filter { url ->
|
||||||
|
platform.os.values.any { os ->
|
||||||
|
url.contains(os, true)
|
||||||
|
}
|
||||||
|
}.ifEmpty {
|
||||||
|
release.assets
|
||||||
|
.filter { asset ->
|
||||||
|
platform.os.values.any { os ->
|
||||||
|
asset.name.contains(os, true) || asset.downloadUrl.contains(os, true)
|
||||||
|
} && asset.downloadUrl.isNotBlank()
|
||||||
|
}.filter { asset ->
|
||||||
|
platform.arch.values.any { arch ->
|
||||||
|
asset.name.contains(arch, ignoreCase = true) ||
|
||||||
|
asset.downloadUrl.contains(
|
||||||
|
arch,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
} && asset.downloadUrl.isNotBlank()
|
||||||
|
}.map { it.downloadUrl }
|
||||||
|
}
|
||||||
|
val platformPackageList =
|
||||||
|
osPackageList.filter { url ->
|
||||||
|
platform.arch.values.any { arch ->
|
||||||
|
url.contains(arch, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platformPackageList.isEmpty()) {
|
||||||
|
throw CefException("Platform not supported by CEF (${platform.os},${platform.arch})")
|
||||||
|
}
|
||||||
|
|
||||||
|
val sortedPackageList =
|
||||||
|
platformPackageList.sortedWith(
|
||||||
|
compareBy<String> {
|
||||||
|
if (it.contains("sdk", true)) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}.thenBy {
|
||||||
|
if (it.endsWith(".tar.gz", true)) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return sortedPackageList.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class GitHubRelease(
|
||||||
|
val body: String,
|
||||||
|
val assets: List<Asset> = emptyList(),
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
data class Asset(
|
||||||
|
val name: String = "",
|
||||||
|
@SerialName("browser_download_url") val downloadUrl: String = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// based on https://github.com/DatL4g/KCEF/blob/master/kcef/src/main/kotlin/dev/datlag/kcef/step/extract/TarGzExtractor.kt
|
||||||
|
internal data object TarGzExtractor {
|
||||||
|
internal fun Path.validate(parent: Path): Boolean =
|
||||||
|
runCatching {
|
||||||
|
this.normalize().startsWith(parent)
|
||||||
|
}.getOrNull() ?: false
|
||||||
|
|
||||||
|
internal fun Path.isSymlink(): Boolean =
|
||||||
|
runCatching {
|
||||||
|
this.isSymbolicLink()
|
||||||
|
}.getOrNull() ?: runCatching {
|
||||||
|
!this.isRegularFile(LinkOption.NOFOLLOW_LINKS)
|
||||||
|
}.getOrNull() ?: false
|
||||||
|
|
||||||
|
internal fun Path.getRealFile(): Path =
|
||||||
|
if (isSymlink()) {
|
||||||
|
runCatching {
|
||||||
|
this.readSymbolicLink()
|
||||||
|
}.getOrNull() ?: this
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun Path.isSame(file: Path?): Boolean {
|
||||||
|
var sourceFile = this.getRealFile()
|
||||||
|
if (!sourceFile.exists()) {
|
||||||
|
sourceFile = this
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetFile = file?.getRealFile() ?: file
|
||||||
|
if (targetFile?.exists() == false) {
|
||||||
|
targetFile = file
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (targetFile == null) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
this == targetFile || runCatching {
|
||||||
|
sourceFile.absolute() == targetFile.absolute() || sourceFile.isSameFileAs(targetFile)
|
||||||
|
}.getOrNull() ?: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun extract(
|
||||||
|
installDir: Path,
|
||||||
|
downloadedFile: Path,
|
||||||
|
bufferSize: Long,
|
||||||
|
) {
|
||||||
|
downloadedFile.inputStream().use { `in` ->
|
||||||
|
GzipCompressorInputStream(`in`).use { gzipIn ->
|
||||||
|
TarArchiveInputStream(gzipIn).use { tarIn ->
|
||||||
|
while (tarIn.nextEntry != null) {
|
||||||
|
val currentEntry = tarIn.currentEntry
|
||||||
|
|
||||||
|
if (currentEntry != null) {
|
||||||
|
val file = installDir / currentEntry.name
|
||||||
|
if (!file.validate(installDir)) {
|
||||||
|
throw CefException("bad archive")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentEntry.isDirectory) {
|
||||||
|
file.createDirectories()
|
||||||
|
} else {
|
||||||
|
BufferedOutputStream(
|
||||||
|
file.outputStream(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE),
|
||||||
|
bufferSize.toInt(),
|
||||||
|
).use { dest ->
|
||||||
|
tarIn.copyTo(dest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
file.setPosixFilePermissions(
|
||||||
|
file.getPosixFilePermissions() +
|
||||||
|
setOf(
|
||||||
|
PosixFilePermission.OWNER_EXECUTE,
|
||||||
|
PosixFilePermission.GROUP_EXECUTE,
|
||||||
|
PosixFilePermission.OTHERS_EXECUTE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (_: UnsupportedOperationException) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downloadedFile.deleteExisting()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun move(installDir: Path) {
|
||||||
|
val releaseFile =
|
||||||
|
Files.walk(installDir).use { s ->
|
||||||
|
s
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
|
.asSequence()
|
||||||
|
.firstOrNull { it.fileName?.toString() == "release" }
|
||||||
|
} ?: (installDir / "release")
|
||||||
|
val releaseFileContents = if (releaseFile.exists()) releaseFile.readText(Charsets.UTF_8) else ""
|
||||||
|
|
||||||
|
val os = Platform.current.os
|
||||||
|
when {
|
||||||
|
os.isLinux -> linuxMove(installDir)
|
||||||
|
os.isMacOSX -> macMove(installDir)
|
||||||
|
os.isWindows -> winMove(installDir)
|
||||||
|
else -> linuxMove(installDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
(installDir / "release").writeText(releaseFileContents)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun linuxMove(installDir: Path) {
|
||||||
|
var foundDir: Path? = null
|
||||||
|
var foundParent: Path? = null
|
||||||
|
|
||||||
|
installDir.listDirectoryEntries().forEach { parent ->
|
||||||
|
if ((parent / "lib").exists()) {
|
||||||
|
foundDir = parent / "lib"
|
||||||
|
foundParent = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foundDir?.let {
|
||||||
|
val target = it.moveTo(installDir / "lib")
|
||||||
|
foundParent?.let { p ->
|
||||||
|
p.deleteRecursively()
|
||||||
|
p.deleteIfExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
installDir.listDirectoryEntries().forEach { deleteCandidate ->
|
||||||
|
if (!deleteCandidate.isSame(target)) {
|
||||||
|
deleteCandidate.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target.listDirectoryEntries().forEach { moveCandidate ->
|
||||||
|
moveCandidate.moveTo(installDir / moveCandidate.fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
target.deleteExisting()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun macMove(installDir: Path) {
|
||||||
|
var foundDir: Path? = null
|
||||||
|
var foundParent: Path? = null
|
||||||
|
|
||||||
|
installDir.listDirectoryEntries().forEach { parent ->
|
||||||
|
if ((parent / "Contents").exists()) {
|
||||||
|
foundDir = parent / "Contents"
|
||||||
|
foundParent = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val target = (installDir / "lib").also { it.createDirectories() }
|
||||||
|
foundDir?.let { contents ->
|
||||||
|
(contents / "Home" / "lib").listDirectoryEntries().forEach { moveCandidate ->
|
||||||
|
moveCandidate.moveTo(target / moveCandidate.fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
(contents / "Frameworks").moveTo(
|
||||||
|
target / "Frameworks",
|
||||||
|
)
|
||||||
|
|
||||||
|
foundParent?.let { p ->
|
||||||
|
p.deleteRecursively()
|
||||||
|
p.deleteIfExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
installDir.listDirectoryEntries().forEach { deleteCandidate ->
|
||||||
|
if (!deleteCandidate.isSame(target)) {
|
||||||
|
deleteCandidate.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target.listDirectoryEntries().forEach { moveCandidate ->
|
||||||
|
moveCandidate.moveTo(installDir / moveCandidate.fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
target.deleteExisting()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun winMove(installDir: Path) {
|
||||||
|
var foundDir: Path? = null
|
||||||
|
|
||||||
|
installDir.listDirectoryEntries().forEach { parent ->
|
||||||
|
if ((parent / "lib").exists()) {
|
||||||
|
foundDir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foundDir?.let {
|
||||||
|
val target = (it / "lib").moveTo(installDir / "lib")
|
||||||
|
(it / "bin").listDirectoryEntries().forEach { moveCandidate ->
|
||||||
|
moveCandidate.moveTo(target / moveCandidate.fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
installDir.listDirectoryEntries().forEach { deleteCandidate ->
|
||||||
|
if (!deleteCandidate.isSame(target)) {
|
||||||
|
deleteCandidate.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
target.listDirectoryEntries().forEach { moveCandidate ->
|
||||||
|
moveCandidate.moveTo(installDir / moveCandidate.fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
target.deleteExisting()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package suwayomi.tachidesk.server.util
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
data class OSInfo(
|
||||||
|
val os: OS,
|
||||||
|
val arch: ARCH,
|
||||||
|
)
|
||||||
|
|
||||||
|
object Platform {
|
||||||
|
private val oses: List<OS.OSCreator> = listOf(OS.OSCreator.MACOSX(), OS.OSCreator.LINUX(), OS.OSCreator.WINDOWS())
|
||||||
|
private val archs: List<ARCH.ARCHCreator> =
|
||||||
|
listOf(ARCH.ARCHCreator.AMD64(), ARCH.ARCHCreator.I386(), ARCH.ARCHCreator.ARM64(), ARCH.ARCHCreator.ARM())
|
||||||
|
|
||||||
|
val current: OSInfo by lazy { getCurrentPlatform() }
|
||||||
|
|
||||||
|
private fun getCurrentPlatform(): OSInfo {
|
||||||
|
val osName = System.getProperty("os.name")
|
||||||
|
val archName = System.getProperty("os.arch")
|
||||||
|
val os = oses.firstNotNullOfOrNull { if (it.matches(osName)) it.create(osName) else null }
|
||||||
|
val arch = archs.firstNotNullOfOrNull { if (it.matches(archName)) it.create(archName) else null }
|
||||||
|
if (os == null || arch == null) {
|
||||||
|
throw UnsupportedOperationException("Unsupported platform tuple $osName,$archName")
|
||||||
|
}
|
||||||
|
return OSInfo(os, arch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class OS(
|
||||||
|
val name: String,
|
||||||
|
vararg val values: String,
|
||||||
|
) {
|
||||||
|
internal abstract class OSCreator(
|
||||||
|
vararg val values: String,
|
||||||
|
) {
|
||||||
|
abstract fun create(name: String): OS
|
||||||
|
|
||||||
|
fun matches(name: String): Boolean =
|
||||||
|
values.any { name.startsWith(it, true) } ||
|
||||||
|
values.contains(
|
||||||
|
name.lowercase(
|
||||||
|
Locale.ENGLISH,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class MACOSX : OSCreator("mac", "darwin", "osx") {
|
||||||
|
override fun create(name: String) = OS.MACOSX(name, *values)
|
||||||
|
}
|
||||||
|
|
||||||
|
class LINUX : OSCreator("linux") {
|
||||||
|
override fun create(name: String) = OS.LINUX(name, *values)
|
||||||
|
}
|
||||||
|
|
||||||
|
class WINDOWS : OSCreator("win", "windows") {
|
||||||
|
override fun create(name: String) = OS.WINDOWS(name, *values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MACOSX(
|
||||||
|
name: String,
|
||||||
|
vararg values: String,
|
||||||
|
) : OS(name, *values)
|
||||||
|
|
||||||
|
class LINUX(
|
||||||
|
name: String,
|
||||||
|
vararg values: String,
|
||||||
|
) : OS(name, *values)
|
||||||
|
|
||||||
|
class WINDOWS(
|
||||||
|
name: String,
|
||||||
|
vararg values: String,
|
||||||
|
) : OS(name, *values)
|
||||||
|
|
||||||
|
val isLinux: Boolean get() = this is LINUX
|
||||||
|
val isMacOSX: Boolean get() = this is MACOSX
|
||||||
|
val isWindows: Boolean get() = this is WINDOWS
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class ARCH(
|
||||||
|
val name: String,
|
||||||
|
vararg val values: String,
|
||||||
|
) {
|
||||||
|
internal abstract class ARCHCreator(
|
||||||
|
vararg val values: String,
|
||||||
|
) {
|
||||||
|
abstract fun create(name: String): ARCH
|
||||||
|
|
||||||
|
fun matches(name: String): Boolean =
|
||||||
|
values.any { name.startsWith(it, true) } ||
|
||||||
|
values.contains(
|
||||||
|
name.lowercase(
|
||||||
|
Locale.ENGLISH,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class AMD64 : ARCHCreator("amd64", "x86_64", "x64") {
|
||||||
|
override fun create(name: String) = ARCH.AMD64(name, *values)
|
||||||
|
}
|
||||||
|
|
||||||
|
class I386 : ARCHCreator("x86", "i386", "i486", "i586", "i686", "i786") {
|
||||||
|
override fun create(name: String) = ARCH.I386(name, *values)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ARM64 : ARCHCreator("arm64", "aarch64") {
|
||||||
|
override fun create(name: String) = ARCH.ARM64(name, *values)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ARM : ARCHCreator("arm") {
|
||||||
|
override fun create(name: String) = ARCH.ARM(name, *values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AMD64(
|
||||||
|
arch: String,
|
||||||
|
vararg values: String,
|
||||||
|
) : ARCH(arch, *values)
|
||||||
|
|
||||||
|
class I386(
|
||||||
|
arch: String,
|
||||||
|
vararg values: String,
|
||||||
|
) : ARCH(arch, *values)
|
||||||
|
|
||||||
|
class ARM64(
|
||||||
|
arch: String,
|
||||||
|
vararg values: String,
|
||||||
|
) : ARCH(arch, *values)
|
||||||
|
|
||||||
|
class ARM(
|
||||||
|
arch: String,
|
||||||
|
vararg values: String,
|
||||||
|
) : ARCH(arch, *values)
|
||||||
|
}
|
||||||
42
server/src/test/kotlin/suwayomi/tachidesk/CefTest.kt
Normal file
42
server/src/test/kotlin/suwayomi/tachidesk/CefTest.kt
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package suwayomi.tachidesk
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import org.koin.core.context.stopKoin
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import suwayomi.tachidesk.server.util.CEFManager
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlin.io.path.ExperimentalPathApi
|
||||||
|
import kotlin.io.path.deleteRecursively
|
||||||
|
import kotlin.io.path.div
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPathApi::class)
|
||||||
|
class CefTest {
|
||||||
|
@Test
|
||||||
|
fun downloadedJbrIsValidForJcef() =
|
||||||
|
runTest {
|
||||||
|
val tempDownload = Files.createTempDirectory("kcef")
|
||||||
|
val module =
|
||||||
|
module {
|
||||||
|
single {
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startKoin {
|
||||||
|
modules(module)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
CEFManager.downloadRelease(tempDownload)
|
||||||
|
assertTrue { CEFManager.isInstallationValid(tempDownload / "release") }
|
||||||
|
} finally {
|
||||||
|
tempDownload.deleteRecursively()
|
||||||
|
stopKoin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ class LooperThread : Thread() {
|
|||||||
|
|
||||||
override fun run() {
|
override fun run() {
|
||||||
Looper.prepare()
|
Looper.prepare()
|
||||||
mHandler = Handler(Looper.myLooper())
|
mHandler = Handler(Looper.myLooper()!!)
|
||||||
latch.countDown()
|
latch.countDown()
|
||||||
Looper.loop()
|
Looper.loop()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user