Files
Suwayomi-Server/server/src/main/kotlin/suwayomi/tachidesk/global/impl/KcefWebView.kt
renovate[bot] 83c94c044d Update dependency com.pinterest.ktlint:ktlint-cli to v1.8.0 (#1786)
* Update dependency com.pinterest.ktlint:ktlint-cli to v1.8.0

* Format

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Syer10 <syer10@users.noreply.github.com>
2025-12-06 12:37:19 -05:00

734 lines
22 KiB
Kotlin

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.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Cookie
import okhttp3.HttpUrl
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 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: KCEFClient? = null
private var browser: KCEFBrowser? = 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 =
KCEF.newClientBlocking().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()),
// 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),
),
)
}
}
}