Files
Suwayomi-Server/server/src/main/kotlin/suwayomi/tachidesk/global/impl/KcefWebView.kt
Constantin Piber 00861d7750 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>
2026-05-19 17:05:59 -04:00

740 lines
23 KiB
Kotlin

package suwayomi.tachidesk.global.impl
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
import org.cef.browser.CefRendering
import org.cef.handler.CefDisplayHandlerAdapter
import org.cef.handler.CefLoadHandler
import org.cef.handler.CefLoadHandlerAdapter
import org.cef.handler.CefRenderHandlerAdapter
import org.cef.handler.CefRequestHandlerAdapter
import org.cef.handler.CefResourceRequestHandler
import org.cef.input.CefTouchEvent
import org.cef.misc.BoolRef
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
import java.awt.Toolkit
import java.awt.datatransfer.DataFlavor
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseEvent
import java.awt.event.MouseWheelEvent
import java.awt.image.BufferedImage
import java.awt.image.DataBufferInt
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Date
import javax.imageio.ImageIO
import javax.swing.JPanel
class KcefWebView {
private val logger = KotlinLogging.logger {}
private val renderHandler = RenderHandler()
private var kcefClient: CefClient? = null
private var browser: CefBrowser? = null
private var width = 1000
private var height = 1000
companion object {
private val networkHelper: NetworkHelper by injectLazy()
fun Cookie.toCefCookie(): CefCookie {
val cookie = this
return CefCookie(
cookie.name,
cookie.value,
if (cookie.hostOnly) {
cookie.domain
} else {
"." + cookie.domain
},
cookie.path,
cookie.secure,
cookie.httpOnly,
Date(),
null,
cookie.expiresAt < 253402300799999L, // okhttp3.internal.http.MAX_DATE
Date(cookie.expiresAt),
)
}
}
@Serializable
sealed class Event
@Serializable
@SerialName("consoleMessage")
private data class ConsoleEvent(
val severity: Int,
val message: String,
val source: String,
val line: Int,
) : Event()
@Serializable
@SerialName("addressChange")
private data class AddressEvent(
val url: String,
val title: String,
) : Event()
@Serializable
@SerialName("statusChange")
private data class StatusEvent(
val message: String,
) : Event()
@Suppress("ArrayInDataClass")
@Serializable
@SerialName("render")
private data class RenderEvent(
val image: ByteArray,
) : Event()
@Serializable
@SerialName("load")
private data class LoadEvent(
val url: String,
val title: String,
val status: Int = 0,
val error: String? = null,
) : Event()
@Serializable
@SerialName("copy")
private data class CopyEvent(
val content: String,
) : Event()
private inner class DisplayHandler : CefDisplayHandlerAdapter() {
override fun onConsoleMessage(
browser: CefBrowser,
level: CefSettings.LogSeverity,
message: String,
source: String,
line: Int,
): Boolean {
WebView.notifyAllClients(
Json.encodeToString<Event>(
ConsoleEvent(level.ordinal, message, source, line),
),
)
logger.trace { "$source:$line: $message" }
return true
}
override fun onAddressChange(
browser: CefBrowser,
frame: CefFrame,
url: String,
) {
if (!frame.isMain) return
this@KcefWebView.browser!!.evaluateJavaScript("return document.title") {
WebView.notifyAllClients(
Json.encodeToString<Event>(
AddressEvent(url, it ?: ""),
),
)
}
flush()
}
override fun onStatusMessage(
browser: CefBrowser,
value: String,
) {
WebView.notifyAllClients(
Json.encodeToString<Event>(
StatusEvent(value),
),
)
}
}
private inner class LoadHandler : CefLoadHandlerAdapter() {
override fun onLoadEnd(
browser: CefBrowser,
frame: CefFrame,
httpStatusCode: Int,
) {
logger.trace { "Load event: ${frame.name} - ${frame.url}" }
if (httpStatusCode > 0 && frame.isMain) handleLoad(frame.url, httpStatusCode)
flush()
}
override fun onLoadError(
browser: CefBrowser,
frame: CefFrame,
errorCode: CefLoadHandler.ErrorCode,
errorText: String,
failedUrl: String,
) {
if (frame.isMain) handleLoad(failedUrl, 0, errorText)
}
}
private inner class RequestHandler : CefRequestHandlerAdapter() {
override fun getResourceRequestHandler(
browser: CefBrowser,
frame: CefFrame,
request: CefRequest,
isNavigation: Boolean,
isDownload: Boolean,
requestInitiator: String,
disableDefaultHandling: BoolRef,
): CefResourceRequestHandler? {
logger.trace { "Load resource: ${frame.name} - ${request.url}" }
return null
}
}
// Loosely based on
// https://github.com/JetBrains/jcef/blob/main/java/org/cef/browser/CefBrowserOsr.java
private inner class RenderHandler : CefRenderHandlerAdapter() {
var myImage: BufferedImage? = null
override fun getViewRect(browser: CefBrowser): Rectangle = Rectangle(0, 0, width, height)
override fun onPaint(
browser: CefBrowser,
popup: Boolean,
dirtyRects: Array<Rectangle>,
buffer: ByteBuffer,
width: Int,
height: Int,
) {
var image = myImage ?: BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB_PRE)
if (image.width != width || image.height != height) {
image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB_PRE)
}
val dst = (image.raster.getDataBuffer() as DataBufferInt).getData()
val src = buffer.order(ByteOrder.LITTLE_ENDIAN).asIntBuffer()
src.get(dst)
myImage = image
val stream = ByteArrayOutputStream()
val success = ImageIO.write(myImage, "png", stream)
if (!success) {
throw IllegalStateException("Failed to convert image to PNG")
}
WebView.notifyAllClients(
Json.encodeToString<Event>(
RenderEvent(stream.toByteArray()),
),
)
}
}
init {
destroy()
kcefClient =
runBlocking {
CefHelper.createClient().apply {
addDisplayHandler(DisplayHandler())
addLoadHandler(LoadHandler())
addRequestHandler(RequestHandler())
}
}
logger.debug { "Start loading cookies" }
CefCookieManager.getGlobalManager().apply {
val cookies = networkHelper.cookieStore.getStoredCookies()
for (cookie in cookies) {
try {
if (!setCookie(
"https://" + cookie.domain,
cookie.toCefCookie(),
)
) {
throw Exception()
}
} catch (e: Exception) {
logger.warn(e) { "Loading cookie ${cookie.name} failed" }
}
}
}
}
fun destroy() {
flush()
browser?.close(true)
browser?.dispose()
browser = null
kcefClient?.dispose()
kcefClient = null
}
fun loadUrl(url: String) {
browser?.close(true)
browser?.dispose()
browser =
kcefClient!!
.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
createImmediately()
}
}
fun resize(
width: Int,
height: Int,
) {
this.width = width
this.height = height
browser?.wasResized(width, height)
}
private fun flush() {
if (browser == null) return
logger.trace { "Start cookie flush" }
CefCookieManager.getGlobalManager().visitAllCookies { it, _, _, _ ->
try {
networkHelper.cookieStore.addAll(
HttpUrl
.Builder()
.scheme("http")
.host(it.domain.removePrefix("."))
.build(),
listOf(
Cookie
.Builder()
.name(it.name)
.value(it.value)
.path(if (it.path.startsWith('/')) it.path else "/" + it.path)
.domain(it.domain.removePrefix("."))
.apply {
if (it.hasExpires) {
expiresAt(it.expires.time)
} else {
expiresAt(Long.MAX_VALUE)
}
if (it.httponly) {
httpOnly()
}
if (it.secure) {
secure()
}
if (!it.domain.startsWith('.')) {
hostOnlyDomain(it.domain.removePrefix("."))
}
}.build(),
),
)
} catch (e: Exception) {
logger.warn(e) { "Writing cookie ${it.name} failed" }
}
return@visitAllCookies true
}
}
private fun keyEvent(
msg: WebView.JsEventMessage,
component: Component,
id: Int,
modifier: Int,
): KeyEvent? {
val char = if (msg.key?.length == 1) msg.key[0] else KeyEvent.CHAR_UNDEFINED
return keyEvent(char, component, id, modifier, msg.key)
}
private fun keyEvent(
char: Char,
component: Component,
id: Int,
modifier: Int,
strKey: String? = null,
): KeyEvent? {
val code =
when (char.uppercaseChar()) {
in 'A'..'Z', in '0'..'9' -> {
char.uppercaseChar().code
}
'&' -> {
KeyEvent.VK_AMPERSAND
}
'*' -> {
KeyEvent.VK_ASTERISK
}
'@' -> {
KeyEvent.VK_AT
}
'\\' -> {
KeyEvent.VK_BACK_SLASH
}
'{' -> {
KeyEvent.VK_BRACELEFT
}
'}' -> {
KeyEvent.VK_BRACERIGHT
}
'^' -> {
KeyEvent.VK_CIRCUMFLEX
}
']' -> {
KeyEvent.VK_CLOSE_BRACKET
}
':' -> {
KeyEvent.VK_COLON
}
',' -> {
KeyEvent.VK_COMMA
}
'$' -> {
KeyEvent.VK_DOLLAR
}
'=' -> {
KeyEvent.VK_EQUALS
}
'€' -> {
KeyEvent.VK_EURO_SIGN
}
'!' -> {
KeyEvent.VK_EXCLAMATION_MARK
}
'>' -> {
KeyEvent.VK_GREATER
}
'(' -> {
KeyEvent.VK_LEFT_PARENTHESIS
}
'<' -> {
KeyEvent.VK_LESS
}
'-' -> {
KeyEvent.VK_MINUS
}
'#' -> {
KeyEvent.VK_NUMBER_SIGN
}
'[' -> {
KeyEvent.VK_OPEN_BRACKET
}
'.' -> {
KeyEvent.VK_PERIOD
}
'+' -> {
KeyEvent.VK_PLUS
}
'\'' -> {
KeyEvent.VK_QUOTE
}
'"' -> {
KeyEvent.VK_QUOTEDBL
}
')' -> {
KeyEvent.VK_RIGHT_PARENTHESIS
}
';' -> {
KeyEvent.VK_SEMICOLON
}
'/' -> {
KeyEvent.VK_SLASH
}
' ' -> {
KeyEvent.VK_SPACE
}
'_' -> {
KeyEvent.VK_UNDERSCORE
}
else -> {
when (strKey) {
"Alt" -> KeyEvent.VK_ALT
"Backspace" -> KeyEvent.VK_BACK_SPACE
"Delete" -> KeyEvent.VK_DELETE
"CapsLock" -> KeyEvent.VK_CAPS_LOCK
"Control" -> KeyEvent.VK_CONTROL
"ArrowDown" -> KeyEvent.VK_DOWN
"End" -> KeyEvent.VK_END
"Enter" -> KeyEvent.VK_ENTER
"Escape" -> KeyEvent.VK_ESCAPE
"F1" -> KeyEvent.VK_F1
"F2" -> KeyEvent.VK_F2
"F3" -> KeyEvent.VK_F3
"F4" -> KeyEvent.VK_F4
"F5" -> KeyEvent.VK_F5
"F6" -> KeyEvent.VK_F6
"F7" -> KeyEvent.VK_F7
"F8" -> KeyEvent.VK_F8
"F9" -> KeyEvent.VK_F9
"F10" -> KeyEvent.VK_F10
"F11" -> KeyEvent.VK_F11
"F12" -> KeyEvent.VK_F12
"Home" -> KeyEvent.VK_HOME
"Insert" -> KeyEvent.VK_INSERT
"ArrowLeft" -> KeyEvent.VK_LEFT
"Meta" -> KeyEvent.VK_META
"NumLock" -> KeyEvent.VK_NUM_LOCK
"PageDown" -> KeyEvent.VK_PAGE_DOWN
"PageUp" -> KeyEvent.VK_PAGE_UP
"Pause" -> KeyEvent.VK_PAUSE
"ArrowRight" -> KeyEvent.VK_RIGHT
"ScrollLock" -> KeyEvent.VK_SCROLL_LOCK
"Shift" -> KeyEvent.VK_SHIFT
"Tab" -> KeyEvent.VK_TAB
"ArrowUp" -> KeyEvent.VK_UP
else -> KeyEvent.VK_UNDEFINED
}
}
}
if (id == KeyEvent.KEY_TYPED) {
if (char == KeyEvent.CHAR_UNDEFINED && code != KeyEvent.VK_ENTER) return null
return KeyEvent(
component,
id,
0L,
modifier,
KeyEvent.VK_UNDEFINED,
if (code == KeyEvent.VK_ENTER) code.toChar() else char,
KeyEvent.KEY_LOCATION_UNKNOWN,
)
}
return KeyEvent(
component,
id,
0L,
modifier,
code,
if (code == KeyEvent.VK_ENTER) code.toChar() else char,
KeyEvent.KEY_LOCATION_STANDARD,
)
}
fun event(msg: WebView.JsEventMessage) {
val component = browser?.uiComponent ?: return
val type = msg.eventType
val clickX = msg.clickX
val clickY = msg.clickY
val modifier =
(
// Alt support is removed, since browsers already translate, so this messes with translation, see #1575
// (if (msg.altKey == true) InputEvent.ALT_DOWN_MASK else 0) or
(if (msg.ctrlKey == true) InputEvent.CTRL_DOWN_MASK else 0) or
(if (msg.shiftKey == true) InputEvent.SHIFT_DOWN_MASK else 0) or
(if (msg.metaKey == true) InputEvent.META_DOWN_MASK else 0)
)
if (type == "wheel") {
val d = msg.deltaY?.toInt() ?: 1
val ev =
MouseWheelEvent(
component,
0,
0L,
modifier,
clickX.toInt(),
clickY.toInt(),
0,
false,
MouseWheelEvent.WHEEL_UNIT_SCROLL,
-d,
1,
)
browser!!.sendMouseWheelEvent(ev)
return
}
if (type == "keydown") {
browser!!.sendKeyEvent(keyEvent(msg, component, KeyEvent.KEY_PRESSED, modifier)!!)
keyEvent(msg, component, KeyEvent.KEY_TYPED, modifier)?.let { browser!!.sendKeyEvent(it) }
return
}
if (type == "keyup") {
browser!!.sendKeyEvent(keyEvent(msg, component, KeyEvent.KEY_RELEASED, modifier)!!)
return
}
if (type == "mousedown" || type == "mouseup" || type == "click") {
val id =
when (type) {
"mousedown" -> MouseEvent.MOUSE_PRESSED
"mouseup" -> MouseEvent.MOUSE_PRESSED
"click" -> MouseEvent.MOUSE_CLICKED
else -> 0
}
val mouseModifier =
when (msg.button ?: 0) {
0 -> MouseEvent.BUTTON1_DOWN_MASK
1 -> MouseEvent.BUTTON2_DOWN_MASK
2 -> MouseEvent.BUTTON3_DOWN_MASK
else -> 0
}
val button =
when (msg.button ?: 0) {
0 -> MouseEvent.BUTTON1
1 -> MouseEvent.BUTTON2
2 -> MouseEvent.BUTTON3
else -> 0
}
val ev =
MouseEvent(
component,
id,
0L,
modifier or mouseModifier,
clickX.toInt(),
clickY.toInt(),
msg.clientX?.toInt() ?: 0,
msg.clientY?.toInt() ?: 0,
1,
true,
button,
)
browser!!.sendMouseEvent(ev)
val evType =
when (type) {
"mousedown" -> CefTouchEvent.EventType.PRESSED
"mouseup" -> CefTouchEvent.EventType.RELEASED
else -> CefTouchEvent.EventType.MOVED
}
val ev2 =
CefTouchEvent(
0,
clickX,
clickY,
10.0f,
10.0f,
0.0f,
1.0f,
evType,
modifier,
CefTouchEvent.PointerType.MOUSE,
)
browser!!.sendTouchEvent(ev2)
return
}
if (type == "mousemove") {
val ev =
MouseEvent(
component,
MouseEvent.MOUSE_MOVED,
0L,
modifier,
clickX.toInt(),
clickY.toInt(),
msg.clientX?.toInt() ?: 0,
msg.clientY?.toInt() ?: 0,
0,
true,
0,
)
browser!!.sendMouseEvent(ev)
return
}
}
fun paste(msg: String) {
val component = browser?.uiComponent ?: return
for (c in msg) {
browser!!.sendKeyEvent(keyEvent(c, component, KeyEvent.KEY_PRESSED, 0)!!)
keyEvent(c, component, KeyEvent.KEY_TYPED, 0)?.let { browser!!.sendKeyEvent(it) }
browser!!.sendKeyEvent(keyEvent(c, component, KeyEvent.KEY_RELEASED, 0)!!)
}
}
fun copy() {
val frame = browser?.focusedFrame ?: return
frame.copy()
val clip =
try {
Toolkit.getDefaultToolkit().getSystemClipboard()
} catch (e: HeadlessException) {
logger.warn(e) { "Failed to get clipboard" }
return
}
val text =
try {
clip.getData(DataFlavor.stringFlavor) as String
} catch (e: Exception) {
logger.warn(e) { "Failed to get clipboard contents" }
return
}
WebView.notifyAllClients(
Json.encodeToString<Event>(
CopyEvent(text),
),
)
}
fun canGoBack(): Boolean = browser!!.canGoBack()
fun goBack() {
browser!!.goBack()
}
fun canGoForward(): Boolean = browser!!.canGoForward()
fun goForward() {
browser!!.goForward()
}
private fun handleLoad(
url: String,
status: Int = 0,
error: String? = null,
) {
browser!!.evaluateJavaScript("return document.title") {
logger.trace { "Load finished with title $it" }
WebView.notifyAllClients(
Json.encodeToString<Event>(
LoadEvent(url, it ?: "", status, error),
),
)
}
}
}