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( 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( AddressEvent(url, it ?: ""), ), ) } flush() } override fun onStatusMessage( browser: CefBrowser, value: String, ) { WebView.notifyAllClients( Json.encodeToString( 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, 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( 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( 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( LoadEvent(url, it ?: "", status, error), ), ) } } }