mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-06-30 09:24:34 -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
|
||||
exit 0
|
||||
|
||||
- name: "Run tests"
|
||||
working-directory: master
|
||||
run: ./gradlew test --stacktrace
|
||||
|
||||
check_docs:
|
||||
name: Validate that all options are documented
|
||||
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.WebViewRenderProcess
|
||||
import android.webkit.WebViewRenderProcessClient
|
||||
import dev.datlag.kcef.KCEF
|
||||
import dev.datlag.kcef.KCEFBrowser
|
||||
import dev.datlag.kcef.KCEFClient
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.cef.CefClient
|
||||
import org.cef.CefSettings
|
||||
import org.cef.browser.CefBrowser
|
||||
import org.cef.browser.CefFrame
|
||||
@@ -87,7 +86,7 @@ import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.math.min
|
||||
import kotlin.reflect.KFunction
|
||||
import kotlin.reflect.full.declaredMemberFunctions
|
||||
import kotlin.reflect.jvm.javaMethod
|
||||
@@ -102,8 +101,8 @@ class KcefWebViewProvider(
|
||||
private val urlHttpMapping: MutableMap<String, String> = mutableMapOf()
|
||||
private var initialRequestData: InitialRequestData? = null
|
||||
|
||||
private var kcefClient: KCEFClient? = null
|
||||
private var browser: KCEFBrowser? = null
|
||||
private var kcefClient: CefClient? = null
|
||||
private var browser: CefBrowser? = null
|
||||
|
||||
private val handler = Handler(view.webViewLooper)
|
||||
|
||||
@@ -115,8 +114,8 @@ class KcefWebViewProvider(
|
||||
private val initHandler: InitBrowserHandler by KoinPlatformTools.defaultContext().get().inject()
|
||||
}
|
||||
|
||||
public interface InitBrowserHandler {
|
||||
public fun init(provider: KcefWebViewProvider): Unit
|
||||
interface InitBrowserHandler {
|
||||
fun init(provider: KcefWebViewProvider): Unit
|
||||
}
|
||||
|
||||
private data class InitialRequestData(
|
||||
@@ -192,7 +191,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
private inner class DisplayHandler : CefDisplayHandlerAdapter() {
|
||||
private class DisplayHandler : CefDisplayHandlerAdapter() {
|
||||
override fun onConsoleMessage(
|
||||
browser: CefBrowser,
|
||||
level: CefSettings.LogSeverity,
|
||||
@@ -220,6 +219,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private inner class LoadHandler : CefLoadHandlerAdapter() {
|
||||
override fun onLoadEnd(
|
||||
browser: CefBrowser,
|
||||
@@ -366,7 +366,7 @@ class KcefWebViewProvider(
|
||||
callback: CefCallback,
|
||||
): Boolean {
|
||||
val data = resolvedData ?: return false
|
||||
val bytesToTransfer = Math.min(bytesToRead, data.size - readOffset)
|
||||
val bytesToTransfer = min(bytesToRead, data.size - readOffset)
|
||||
Log.v(
|
||||
TAG,
|
||||
"readResponse: $readOffset/${data.size}, reading $bytesToRead->$bytesToTransfer",
|
||||
@@ -378,7 +378,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
private inner class WebResponseResourceHandler(
|
||||
private class WebResponseResourceHandler(
|
||||
val webResponse: WebResourceResponse,
|
||||
) : ArrayResponseResourceHandler() {
|
||||
override fun processRequest(
|
||||
@@ -408,7 +408,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
private inner class HtmlResponseResourceHandler(
|
||||
private class HtmlResponseResourceHandler(
|
||||
val html: String,
|
||||
) : ArrayResponseResourceHandler() {
|
||||
override fun processRequest(
|
||||
@@ -439,7 +439,7 @@ class KcefWebViewProvider(
|
||||
view,
|
||||
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) }
|
||||
|
||||
@@ -466,7 +466,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
if (response == null) {
|
||||
// prefer user's response override
|
||||
urlHttpMapping.get(request.url.trimEnd('/'))?.let {
|
||||
urlHttpMapping[request.url.trimEnd('/')]?.let {
|
||||
return HtmlResponseResourceHandler(it)
|
||||
}
|
||||
}
|
||||
@@ -475,6 +475,7 @@ class KcefWebViewProvider(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private inner class RequestHandler : CefRequestHandlerAdapter() {
|
||||
override fun getResourceRequestHandler(
|
||||
browser: CefBrowser,
|
||||
@@ -484,11 +485,13 @@ class KcefWebViewProvider(
|
||||
isDownload: Boolean,
|
||||
requestInitiator: String,
|
||||
disableDefaultHandling: BoolRef,
|
||||
): CefResourceRequestHandler? = ResourceRequestHandler()
|
||||
): CefResourceRequestHandler = ResourceRequestHandler()
|
||||
|
||||
override fun onRenderProcessTerminated(
|
||||
browser: CefBrowser,
|
||||
status: CefRequestHandler.TerminationStatus,
|
||||
errorCode: Int,
|
||||
errorString: String,
|
||||
) {
|
||||
handler.post {
|
||||
viewClient.onRenderProcessGone(
|
||||
@@ -507,13 +510,13 @@ class KcefWebViewProvider(
|
||||
override fun onRequestMediaAccessPermission(
|
||||
browser: CefBrowser,
|
||||
frame: CefFrame,
|
||||
requesting_url: String,
|
||||
requested_permissions: Int,
|
||||
requestingUrl: String,
|
||||
requestedPermissions: Int,
|
||||
callback: CefMediaAccessCallback,
|
||||
): Boolean {
|
||||
handler.post {
|
||||
Log.v(TAG, "Checking permission for $requesting_url: $requested_permissions")
|
||||
chromeClient.onPermissionRequest(CefPermissionRequest(requesting_url, requested_permissions, callback))
|
||||
Log.v(TAG, "Checking permission for $requestingUrl: $requestedPermissions")
|
||||
chromeClient.onPermissionRequest(CefPermissionRequest(requestingUrl, requestedPermissions, callback))
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -526,7 +529,8 @@ class KcefWebViewProvider(
|
||||
Log.v(TAG, "KcefWebViewProvider: initialize")
|
||||
destroy()
|
||||
kcefClient =
|
||||
KCEF.newClientBlocking().apply {
|
||||
runBlocking {
|
||||
CefHelper.createClient().apply {
|
||||
addDisplayHandler(DisplayHandler())
|
||||
addLoadHandler(LoadHandler())
|
||||
addRequestHandler(RequestHandler())
|
||||
@@ -537,6 +541,7 @@ class KcefWebViewProvider(
|
||||
config.jsCancelFunction = QUERY_CANCEL_FN
|
||||
addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler()))
|
||||
}
|
||||
}
|
||||
initHandler.init(this)
|
||||
}
|
||||
|
||||
@@ -613,6 +618,7 @@ class KcefWebViewProvider(
|
||||
.createBrowser(
|
||||
loadUrl,
|
||||
CefRendering.OFFSCREEN,
|
||||
false,
|
||||
).apply {
|
||||
// NOTE: Without this, we don't seem to be receiving any events
|
||||
createImmediately()
|
||||
@@ -637,6 +643,7 @@ class KcefWebViewProvider(
|
||||
.createBrowser(
|
||||
url,
|
||||
CefRendering.OFFSCREEN,
|
||||
false,
|
||||
).apply {
|
||||
// NOTE: Without this, we don't seem to be receiving any events
|
||||
createImmediately()
|
||||
@@ -662,23 +669,15 @@ class KcefWebViewProvider(
|
||||
browser?.close(true)
|
||||
browser?.dispose()
|
||||
chromeClient.onProgressChanged(view, 0)
|
||||
val url = baseUrl ?: "about:blank"
|
||||
urlHttpMapping[url.trimEnd('/')] = data
|
||||
|
||||
browser =
|
||||
(
|
||||
baseUrl?.let { url ->
|
||||
urlHttpMapping.put(url.trimEnd('/'), data)
|
||||
kcefClient!!.createBrowser(
|
||||
kcefClient!!
|
||||
.createBrowser(
|
||||
url,
|
||||
CefRendering.OFFSCREEN,
|
||||
)
|
||||
}
|
||||
?: run {
|
||||
kcefClient!!.createBrowserWithHtml(
|
||||
data,
|
||||
KCEFBrowser.BLANK_URI,
|
||||
CefRendering.OFFSCREEN,
|
||||
)
|
||||
}
|
||||
false,
|
||||
).apply {
|
||||
// NOTE: Without this, we don't seem to be receiving any events
|
||||
createImmediately()
|
||||
@@ -692,11 +691,11 @@ class KcefWebViewProvider(
|
||||
) {
|
||||
browser!!.evaluateJavaScript(
|
||||
script.removePrefix("javascript:"),
|
||||
)
|
||||
{
|
||||
Log.v(TAG, "JS returned: $it")
|
||||
it?.let { handler.post { resultCallback?.onReceiveValue(it) } }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun saveWebArchive(filename: String): Unit = throw RuntimeException("Stub!")
|
||||
@@ -838,6 +837,7 @@ class KcefWebViewProvider(
|
||||
|
||||
override fun getWebChromeClient(): WebChromeClient = chromeClient
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun setPictureListener(listener: PictureListener): Unit = throw RuntimeException("Stub!")
|
||||
|
||||
@Serializable
|
||||
@@ -860,7 +860,7 @@ class KcefWebViewProvider(
|
||||
obj: Any,
|
||||
interfaceName: String,
|
||||
) {
|
||||
val cls = obj::class as KClass<Any>
|
||||
val cls = obj::class
|
||||
mappings.addAll(
|
||||
cls.declaredMemberFunctions.map {
|
||||
// This is ridiculous, but necessary, otherwise "public final" throws
|
||||
@@ -922,7 +922,8 @@ class KcefWebViewProvider(
|
||||
override fun getRendererPriorityWaivedWhenNotVisible(): Boolean = throw RuntimeException("Stub!")
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
override fun setTextClassifier(textClassifier: TextClassifier?) {}
|
||||
override fun setTextClassifier(textClassifier: TextClassifier?) {
|
||||
}
|
||||
|
||||
override fun getTextClassifier(): TextClassifier = TextClassifier.NO_OP
|
||||
|
||||
@@ -948,11 +949,13 @@ class KcefWebViewProvider(
|
||||
override fun onProvideAutofillVirtualStructure(
|
||||
@SuppressWarnings("unused") structure: android.view.ViewStructure,
|
||||
@SuppressWarnings("unused") flags: Int,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
override fun autofill(
|
||||
@SuppressWarnings("unused") values: SparseArray<AutofillValue>,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
override fun isVisibleToUserForAutofill(
|
||||
@SuppressWarnings("unused") virtualId: Int,
|
||||
@@ -963,7 +966,8 @@ class KcefWebViewProvider(
|
||||
override fun onProvideContentCaptureStructure(
|
||||
@SuppressWarnings("unused") structure: android.view.ViewStructure,
|
||||
@SuppressWarnings("unused") flags: Int,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
override fun getAccessibilityNodeProvider(): AccessibilityNodeProvider = throw RuntimeException("Stub!")
|
||||
|
||||
@@ -1033,7 +1037,8 @@ class KcefWebViewProvider(
|
||||
override fun onMovedToDisplay(
|
||||
displayId: Int,
|
||||
config: Configuration,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
override fun onVisibilityChanged(
|
||||
changedView: View,
|
||||
|
||||
@@ -10,8 +10,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- .
|
||||
|
||||
### Changed
|
||||
- (Database/H2) Use the latest H2 database engine
|
||||
- (Startup) Crash on startup if an unrecoverable error happens
|
||||
- (**Database/H2**) Use the latest H2 database engine
|
||||
- (**Startup**) Crash on startup if an unrecoverable error happens
|
||||
- (**WebView**) Use JCEF directly and update to newest Chromium
|
||||
|
||||
### Fixed
|
||||
- (**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 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.
|
||||
|
||||
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.
|
||||
|
||||
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 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.
|
||||
|
||||
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
|
||||
### 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.
|
||||
|
||||
@@ -25,6 +25,7 @@ allprojects {
|
||||
maven("https://github.com/Suwayomi/Suwayomi-Server/raw/android-jar/")
|
||||
maven("https://jitpack.io")
|
||||
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 webviewJbrRelease = "jbr-release-25.0.3b475.60"
|
||||
|
||||
private val getCommitCount = {
|
||||
runCatching {
|
||||
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.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
|
||||
```
|
||||
server.downloadAsCbz = true
|
||||
|
||||
@@ -17,6 +17,7 @@ xmlserialization = "0.91.3"
|
||||
ktlint = "1.8.0"
|
||||
koin = "4.2.1"
|
||||
moko = "0.26.4"
|
||||
jcef = "144.0.15-g72717cf-chromium-144.0.7559.172-api-1.21-262-b34"
|
||||
|
||||
[libraries]
|
||||
# Kotlin
|
||||
@@ -156,7 +157,9 @@ cron4j = "it.sauronsoftware.cron4j:cron4j:2.2.5"
|
||||
cronUtils = "com.cronutils:cron-utils:9.2.1"
|
||||
|
||||
# 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
|
||||
jwt = "com.auth0:java-jwt:4.5.2"
|
||||
@@ -213,7 +216,7 @@ shared = [
|
||||
"dex2jar-tools",
|
||||
"apk-parser",
|
||||
"jackson-annotations",
|
||||
"kcef"
|
||||
"jcef",
|
||||
]
|
||||
|
||||
sharedTest = [
|
||||
|
||||
@@ -37,6 +37,10 @@ dependencies {
|
||||
implementation(libs.bundles.shared)
|
||||
testImplementation(libs.bundles.sharedTest)
|
||||
|
||||
// WebView
|
||||
implementation(libs.gluegen)
|
||||
implementation(libs.jogl)
|
||||
|
||||
// OkHttp
|
||||
implementation(libs.bundles.okhttp)
|
||||
implementation(libs.okio)
|
||||
@@ -159,6 +163,8 @@ buildConfig {
|
||||
|
||||
buildConfigField("String", "GITHUB", quoteWrap("https://github.com/Suwayomi/Suwayomi-Server"))
|
||||
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 {
|
||||
@@ -172,6 +178,7 @@ tasks {
|
||||
"Specification-Version" to getTachideskVersion(),
|
||||
"Implementation-Version" to getTachideskRevision(),
|
||||
"Multi-Release" to true, // needed for polyglot
|
||||
"X-JBR-Release" to webviewJbrRelease,
|
||||
)
|
||||
}
|
||||
archiveBaseName.set(rootProject.name)
|
||||
@@ -182,7 +189,11 @@ tasks {
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
useJUnitPlatform {
|
||||
if (!project.hasProperty("masstest")) {
|
||||
exclude("**/masstest/*")
|
||||
}
|
||||
}
|
||||
testLogging {
|
||||
showStandardStreams = true
|
||||
events("passed", "skipped", "failed")
|
||||
|
||||
@@ -1014,6 +1014,14 @@ class ServerConfig(
|
||||
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"),
|
||||
OPDS("OPDS"),
|
||||
KOREADER_SYNC("KOReader sync"),
|
||||
WEB_VIEW("WebView"),
|
||||
;
|
||||
|
||||
override fun toString(): String = value
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
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 io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl
|
||||
import org.cef.CefClient
|
||||
import org.cef.CefSettings
|
||||
import org.cef.browser.CefBrowser
|
||||
import org.cef.browser.CefFrame
|
||||
@@ -26,6 +25,9 @@ import org.cef.network.CefCookie
|
||||
import org.cef.network.CefCookieManager
|
||||
import org.cef.network.CefRequest
|
||||
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.HeadlessException
|
||||
import java.awt.Rectangle
|
||||
@@ -47,8 +49,8 @@ import javax.swing.JPanel
|
||||
class KcefWebView {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private val renderHandler = RenderHandler()
|
||||
private var kcefClient: KCEFClient? = null
|
||||
private var browser: KCEFBrowser? = null
|
||||
private var kcefClient: CefClient? = null
|
||||
private var browser: CefBrowser? = null
|
||||
private var width = 1000
|
||||
private var height = 1000
|
||||
|
||||
@@ -76,7 +78,8 @@ class KcefWebView {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable sealed class Event
|
||||
@Serializable
|
||||
sealed class Event
|
||||
|
||||
@Serializable
|
||||
@SerialName("consoleMessage")
|
||||
@@ -247,11 +250,13 @@ class KcefWebView {
|
||||
init {
|
||||
destroy()
|
||||
kcefClient =
|
||||
KCEF.newClientBlocking().apply {
|
||||
runBlocking {
|
||||
CefHelper.createClient().apply {
|
||||
addDisplayHandler(DisplayHandler())
|
||||
addLoadHandler(LoadHandler())
|
||||
addRequestHandler(RequestHandler())
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug { "Start loading cookies" }
|
||||
CefCookieManager.getGlobalManager().apply {
|
||||
@@ -289,6 +294,7 @@ class KcefWebView {
|
||||
.createBrowser(
|
||||
url,
|
||||
CefRendering.CefRenderingWithHandler(renderHandler, JPanel()),
|
||||
false,
|
||||
// NOTE: with a context, we don't seem to be getting any cookies
|
||||
).apply {
|
||||
// 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.ConfigValue
|
||||
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.createAppModule
|
||||
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.settings.SettingsRegistry
|
||||
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
|
||||
import suwayomi.tachidesk.server.util.CEFManager
|
||||
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
|
||||
import suwayomi.tachidesk.server.util.ExitCode
|
||||
import suwayomi.tachidesk.server.util.SystemTray
|
||||
@@ -518,56 +517,8 @@ fun applicationSetup() {
|
||||
// start DownloadManager and restore + resume downloads
|
||||
DownloadManager.restoreAndResumeDownloads()
|
||||
|
||||
// asynchronously initialize CEF
|
||||
GlobalScope.launch {
|
||||
val logger = KotlinLogging.logger("KCEF")
|
||||
KCEF.init(
|
||||
builder = {
|
||||
progress {
|
||||
var lastNum = -1
|
||||
onDownloading {
|
||||
val num = it.roundToInt()
|
||||
if (num > lastNum) {
|
||||
lastNum = num
|
||||
logger.info { "KCEF download progress: $num%" }
|
||||
CEFManager.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
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() {
|
||||
Looper.prepare()
|
||||
mHandler = Handler(Looper.myLooper())
|
||||
mHandler = Handler(Looper.myLooper()!!)
|
||||
latch.countDown()
|
||||
Looper.loop()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user