diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 210e11357..506924328 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -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 diff --git a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/CefHelper.kt b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/CefHelper.kt new file mode 100644 index 000000000..62d966d94 --- /dev/null +++ b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/CefHelper.kt @@ -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.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 { + 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) +} diff --git a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefHelper.kt b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefHelper.kt new file mode 100644 index 000000000..0eee6ff05 --- /dev/null +++ b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefHelper.kt @@ -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 = 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 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(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() + } +} diff --git a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefWebViewProvider.kt b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefWebViewProvider.kt index 4d0c8dcbb..cc8878f36 100644 --- a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefWebViewProvider.kt +++ b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefWebViewProvider.kt @@ -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 = 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 + 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, - ) {} + ) { + } 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, diff --git a/CHANGELOG.md b/CHANGELOG.md index 72864c60e..d1ce06d24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c4a64a77c..7b35d3f9b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/build.gradle.kts b/build.gradle.kts index 050f90c93..343fa2846 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") } } diff --git a/buildSrc/src/main/kotlin/Constants.kt b/buildSrc/src/main/kotlin/Constants.kt index 9c569d740..fb954a8c3 100644 --- a/buildSrc/src/main/kotlin/Constants.kt +++ b/buildSrc/src/main/kotlin/Constants.kt @@ -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() diff --git a/docs/Configuring-Suwayomi‐Server.md b/docs/Configuring-Suwayomi‐Server.md index 77bc281c8..9a8787c4f 100644 --- a/docs/Configuring-Suwayomi‐Server.md +++ b/docs/Configuring-Suwayomi‐Server.md @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f0775cbea..3f3dcf346 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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 = [ diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 3ed22bbe0..55378963b 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -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") diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt index 3b23f0b31..9c866137a 100644 --- a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/ServerConfig.kt @@ -1014,6 +1014,14 @@ class ServerConfig( description = "Use Hikari Connection Pool to connect to the database.", ) + val kcefEnabled: MutableStateFlow by BooleanSetting( + protoNumber = 86, + group = SettingGroup.WEB_VIEW, + privacySafe = true, + defaultValue = true, + description = "Enable the WebView via CEF (Chromium)" + ) + /** ****************************************************************** **/ diff --git a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingGroup.kt b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingGroup.kt index 5a329e3db..0acd9af88 100644 --- a/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingGroup.kt +++ b/server/server-config/src/main/kotlin/suwayomi/tachidesk/server/settings/SettingGroup.kt @@ -17,6 +17,7 @@ enum class SettingGroup( CLOUDFLARE("Cloudflare"), OPDS("OPDS"), KOREADER_SYNC("KOReader sync"), + WEB_VIEW("WebView"), ; override fun toString(): String = value diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/impl/KcefWebView.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/KcefWebView.kt index 7fbfc023a..27d3225d8 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/global/impl/KcefWebView.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/KcefWebView.kt @@ -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,10 +250,12 @@ class KcefWebView { init { destroy() kcefClient = - KCEF.newClientBlocking().apply { - addDisplayHandler(DisplayHandler()) - addLoadHandler(LoadHandler()) - addRequestHandler(RequestHandler()) + runBlocking { + CefHelper.createClient().apply { + addDisplayHandler(DisplayHandler()) + addLoadHandler(LoadHandler()) + addRequestHandler(RequestHandler()) + } } logger.debug { "Start loading cookies" } @@ -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 diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt index f164a493f..04d446448 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt @@ -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%" } - } - } - } - 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() }, - ) + CEFManager.init() } - - Runtime.getRuntime().addShutdownHook( - thread(start = false) { - val logger = KotlinLogging.logger("KCEF") - logger.debug { "Shutting down KCEF" } - KCEF.disposeBlocking() - logger.debug { "KCEF shutdown complete" } - }, - ) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/util/CEFManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/util/CEFManager.kt new file mode 100644 index 000000000..39225db16 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/util/CEFManager.kt @@ -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() } + 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() } + + 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 { + 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 = 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() + } + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/util/Platform.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/util/Platform.kt new file mode 100644 index 000000000..87f600f1c --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/util/Platform.kt @@ -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 = listOf(OS.OSCreator.MACOSX(), OS.OSCreator.LINUX(), OS.OSCreator.WINDOWS()) + private val archs: List = + 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) +} diff --git a/server/src/test/kotlin/suwayomi/tachidesk/CefTest.kt b/server/src/test/kotlin/suwayomi/tachidesk/CefTest.kt new file mode 100644 index 000000000..ca55bb116 --- /dev/null +++ b/server/src/test/kotlin/suwayomi/tachidesk/CefTest.kt @@ -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() + } + } +} diff --git a/server/src/test/kotlin/suwayomi/tachidesk/LooperTest.kt b/server/src/test/kotlin/suwayomi/tachidesk/LooperTest.kt index d56ecd052..6eaaa9a8b 100644 --- a/server/src/test/kotlin/suwayomi/tachidesk/LooperTest.kt +++ b/server/src/test/kotlin/suwayomi/tachidesk/LooperTest.kt @@ -17,7 +17,7 @@ class LooperThread : Thread() { override fun run() { Looper.prepare() - mHandler = Handler(Looper.myLooper()) + mHandler = Handler(Looper.myLooper()!!) latch.countDown() Looper.loop() }