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:
@@ -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,16 +529,18 @@ class KcefWebViewProvider(
|
||||
Log.v(TAG, "KcefWebViewProvider: initialize")
|
||||
destroy()
|
||||
kcefClient =
|
||||
KCEF.newClientBlocking().apply {
|
||||
addDisplayHandler(DisplayHandler())
|
||||
addLoadHandler(LoadHandler())
|
||||
addRequestHandler(RequestHandler())
|
||||
addPermissionHandler(PermissionHandler())
|
||||
runBlocking {
|
||||
CefHelper.createClient().apply {
|
||||
addDisplayHandler(DisplayHandler())
|
||||
addLoadHandler(LoadHandler())
|
||||
addRequestHandler(RequestHandler())
|
||||
addPermissionHandler(PermissionHandler())
|
||||
|
||||
val config = CefMessageRouter.CefMessageRouterConfig()
|
||||
config.jsQueryFunction = QUERY_FN
|
||||
config.jsCancelFunction = QUERY_CANCEL_FN
|
||||
addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler()))
|
||||
val config = CefMessageRouter.CefMessageRouterConfig()
|
||||
config.jsQueryFunction = QUERY_FN
|
||||
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,27 +669,19 @@ 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(
|
||||
url,
|
||||
CefRendering.OFFSCREEN,
|
||||
)
|
||||
kcefClient!!
|
||||
.createBrowser(
|
||||
url,
|
||||
CefRendering.OFFSCREEN,
|
||||
false,
|
||||
).apply {
|
||||
// NOTE: Without this, we don't seem to be receiving any events
|
||||
createImmediately()
|
||||
}
|
||||
?: run {
|
||||
kcefClient!!.createBrowserWithHtml(
|
||||
data,
|
||||
KCEFBrowser.BLANK_URI,
|
||||
CefRendering.OFFSCREEN,
|
||||
)
|
||||
}
|
||||
).apply {
|
||||
// NOTE: Without this, we don't seem to be receiving any events
|
||||
createImmediately()
|
||||
}
|
||||
Log.d(TAG, "Page loaded from data at base URL $baseUrl")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user