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

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

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

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