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:
Constantin Piber
2026-05-19 23:05:59 +02:00
committed by GitHub
parent fff291cdb5
commit 00861d7750
19 changed files with 1059 additions and 124 deletions

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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.

View File

@@ -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")
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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 = [

View File

@@ -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")

View File

@@ -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)"
)
/** ****************************************************************** **/

View File

@@ -17,6 +17,7 @@ enum class SettingGroup(
CLOUDFLARE("Cloudflare"),
OPDS("OPDS"),
KOREADER_SYNC("KOReader sync"),
WEB_VIEW("WebView"),
;
override fun toString(): String = value

View File

@@ -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

View File

@@ -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" }
},
)
}

View File

@@ -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()
}
}
}
}

View File

@@ -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)
}

View 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()
}
}
}

View File

@@ -17,7 +17,7 @@ class LooperThread : Thread() {
override fun run() {
Looper.prepare()
mHandler = Handler(Looper.myLooper())
mHandler = Handler(Looper.myLooper()!!)
latch.countDown()
Looper.loop()
}