From a79dc580a55c636ac250b5903a327192f6b03c4b Mon Sep 17 00:00:00 2001 From: Constantin Piber <59023762+cpiber@users.noreply.github.com> Date: Tue, 1 Jul 2025 23:28:41 +0200 Subject: [PATCH] Browser Webview (#1486) * WebView: Add initial controller Co-authored-by: Mitchell Syer * WebView: Prepare page * WebView: Basic HTML setup * WebView: Improve navigation * WebView: Refactor message class deserialization * WebView: Refactor event message serialization * WebView: Handle click events * WebView: Fix events after refactor * WebView: Fix normalizing of URLs * WebView: HTML remove navigation buttons * WebView: Handle more events * WebView: Handle document change in events * WebView: Refactor to send mutation events * WebView: More mouse events * WebView: Include bubbles, cancelable in event Those seem to be important * WebView: Attempt to support nested iframe * WebView: Handle long titles * WebView: Avoid setting invalid url * WebView: Send mousemove * WebView: Start switch to canvas-based render * WebView: Send on every render * WebView: Dynamic size * WebView: Keyboard events * WebView: Handle mouse events in CEF This is important because JS can't click into iFrames, meaning the previous solution doesn't work for captchas * WebView: Cleanup * WebView: Cleanup 2 * WebView: Document title * WebView: Also send title on address change * WebView: Load and flush cookies from store * WebView: remove outdated TODOs * Offline WebView: Load cookies from store * Cleanup * Add KcefCookieManager, need to figure out how to inject it * ktLintFormat * Fix a few cookie bugs * Fix Webview on Windows * Minor cleanup * WebView: Remove /tmp image write, lint * Remove custom cookie manager * Multiple cookie fixes * Minor fix * Minor cleanup and add support for MacOS meta key * Get enter working * WebView HTML: Make responsive for mobile pages * WebView: Translate touch events to mouse scroll * WebView: Overlay an actual input to allow typing on mobile Browsers will only show the keyboard if an input is focused. This also removes the `tabstop` hack. * WebView: Protect against occasional NullPointerException * WebView: Use float for clientX/Y * WebView: Fix ChromeAndroid being a pain * Simplify enter fix * NetworkHelper: Fix cache * Improve CookieStore url matching, fix another cookie conversion issue * Move distinctBy * WebView: Mouse direction toggle * Remove accidentally copied comment --------- Co-authored-by: Mitchell Syer --- .../io/sharedprefs/JavaSharedPreferences.kt | 24 +- .../webkit/KcefWebViewProvider.kt | 8 + .../kanade/tachiyomi/network/NetworkHelper.kt | 4 +- .../network/PersistentCookieStore.kt | 170 ++++-- .../interceptor/CloudflareInterceptor.kt | 1 + .../suwayomi/tachidesk/global/GlobalAPI.kt | 6 + .../global/controller/WebViewController.kt | 38 ++ .../tachidesk/global/impl/KcefWebView.kt | 569 ++++++++++++++++++ .../suwayomi/tachidesk/global/impl/WebView.kt | 105 ++++ .../suwayomi/tachidesk/server/ServerSetup.kt | 132 ++-- server/src/main/resources/webview.html | 422 +++++++++++++ 11 files changed, 1361 insertions(+), 118 deletions(-) create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/global/controller/WebViewController.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/global/impl/KcefWebView.kt create mode 100644 server/src/main/kotlin/suwayomi/tachidesk/global/impl/WebView.kt create mode 100644 server/src/main/resources/webview.html diff --git a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/io/sharedprefs/JavaSharedPreferences.kt b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/io/sharedprefs/JavaSharedPreferences.kt index 3e0f4416..b30c1d99 100644 --- a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/io/sharedprefs/JavaSharedPreferences.kt +++ b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/io/sharedprefs/JavaSharedPreferences.kt @@ -96,7 +96,7 @@ class JavaSharedPreferences( } else { preferences.decodeValueOrNull(SetSerializer(String.serializer()), key) } - } catch (e: SerializationException) { + } catch (_: SerializationException) { throw ClassCastException("$key was not a StringSet") } } @@ -153,11 +153,12 @@ class JavaSharedPreferences( key: String, value: String?, ): SharedPreferences.Editor { - if (value != null) { - actions += Action.Add(key, value) - } else { - actions += Action.Remove(key) - } + actions += + if (value != null) { + Action.Add(key, value) + } else { + Action.Remove(key) + } return this } @@ -165,11 +166,12 @@ class JavaSharedPreferences( key: String, values: MutableSet?, ): SharedPreferences.Editor { - if (values != null) { - actions += Action.Add(key, values) - } else { - actions += Action.Remove(key) - } + actions += + if (values != null) { + Action.Add(key, values) + } else { + Action.Remove(key) + } return this } 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 5a5921c4..bfc99b25 100644 --- a/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefWebViewProvider.kt +++ b/AndroidCompat/src/main/java/xyz/nulldev/androidcompat/webkit/KcefWebViewProvider.kt @@ -82,6 +82,7 @@ import org.cef.network.CefPostData import org.cef.network.CefPostDataElement import org.cef.network.CefRequest import org.cef.network.CefResponse +import org.koin.mp.KoinPlatformTools import java.io.BufferedWriter import java.io.File import java.io.IOException @@ -110,6 +111,12 @@ class KcefWebViewProvider( const val TAG = "KcefWebViewProvider" const val QUERY_FN = "__\$_suwayomiQuery" const val QUERY_CANCEL_FN = "__\$_suwayomiQueryCancel" + + private val initHandler: InitBrowserHandler by KoinPlatformTools.defaultContext().get().inject() + } + + public interface InitBrowserHandler { + public fun init(provider: KcefWebViewProvider): Unit } private class CefWebResourceRequest( @@ -451,6 +458,7 @@ class KcefWebViewProvider( config.jsCancelFunction = QUERY_CANCEL_FN addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler())) } + initHandler.init(this) } // Deprecated - should never be called diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt index 3a6c7255..503bb1b5 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/NetworkHelper.kt @@ -25,10 +25,10 @@ import okhttp3.OkHttpClient import okhttp3.brotli.BrotliInterceptor import okhttp3.logging.HttpLoggingInterceptor import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource -import java.io.File import java.net.CookieHandler import java.net.CookieManager import java.net.CookiePolicy +import java.nio.file.Files import java.util.concurrent.TimeUnit class NetworkHelper( @@ -79,7 +79,7 @@ class NetworkHelper( .callTimeout(2, TimeUnit.MINUTES) .cache( Cache( - directory = File.createTempFile("tachidesk_network_cache", null), + directory = Files.createTempDirectory("tachidesk_network_cache").toFile(), maxSize = 5L * 1024 * 1024, // 5 MiB ), ).addInterceptor(UncaughtExceptionInterceptor()) diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/PersistentCookieStore.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/PersistentCookieStore.kt index 6e012316..29772a0c 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/PersistentCookieStore.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/PersistentCookieStore.kt @@ -8,8 +8,6 @@ import okio.withLock import java.net.CookieStore import java.net.HttpCookie import java.net.URI -import java.net.URL -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.locks.ReentrantLock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -18,30 +16,40 @@ import kotlin.time.Duration.Companion.seconds class PersistentCookieStore( context: Context, ) : CookieStore { - private val cookieMap = ConcurrentHashMap>() + private val cookieMap = mutableMapOf>() private val prefs = context.getSharedPreferences("cookie_store", Context.MODE_PRIVATE) private val lock = ReentrantLock() init { - val domains = - prefs.all.keys - .map { it.substringBeforeLast(".") } - .toSet() - domains.forEach { domain -> - val cookies = prefs.getStringSet(domain, emptySet()) - if (!cookies.isNullOrEmpty()) { - try { - val url = "http://$domain".toHttpUrlOrNull() ?: return@forEach - val nonExpiredCookies = - cookies - .mapNotNull { Cookie.parse(url, it) } - .filter { !it.hasExpired() } - cookieMap[domain] = nonExpiredCookies - } catch (e: Exception) { - // Ignore + lock.withLock { + val domains = + prefs.all.keys + .map { it.substringBeforeLast(".") } + .toSet() + val domainsToSave = mutableSetOf() + domains.forEach { domain -> + val cookies = prefs.getStringSet(domain, emptySet()) + if (!cookies.isNullOrEmpty()) { + try { + val url = "http://$domain".toHttpUrlOrNull() ?: return@forEach + val nonExpiredCookies = + cookies + .mapNotNull { Cookie.parse(url, it) } + .filter { !it.hasExpired() } + .groupBy { it.domain } + .mapValues { it.value.distinctBy { it.name } } + nonExpiredCookies.forEach { (domain, cookies) -> + cookieMap[domain] = cookies + domainsToSave.add(domain) + } + domainsToSave.add(domain) + } catch (_: Exception) { + // Ignore + } } } + saveToDisk(domainsToSave) } } @@ -50,9 +58,10 @@ class PersistentCookieStore( cookies: List, ) { lock.withLock { + val domainsToSave = mutableSetOf() // Append or replace the cookies for this domain. - val cookiesForDomain = cookieMap[url.host].orEmpty().toMutableList() for (cookie in cookies) { + val cookiesForDomain = cookieMap[cookie.domain].orEmpty().toMutableList() // Find a cookie with the same name. Replace it if found, otherwise add a new one. val pos = cookiesForDomain.indexOfFirst { it.name == cookie.name } if (pos == -1) { @@ -60,10 +69,11 @@ class PersistentCookieStore( } else { cookiesForDomain[pos] = cookie } + cookieMap[cookie.domain] = cookiesForDomain + domainsToSave.add(cookie.domain) } - cookieMap[url.host] = cookiesForDomain - saveToDisk(url.toUrl()) + saveToDisk(domainsToSave.toSet()) } } @@ -85,48 +95,66 @@ class PersistentCookieStore( override fun get(uri: URI): List { val url = uri.toURL() - return get(url.host).map { + return get(url.toHttpUrlOrNull()!!).map { it.toHttpCookie() } } - fun get(url: HttpUrl): List = get(url.host) + fun get(url: HttpUrl): List = + lock.withLock { + cookieMap.entries + .filter { + url.host.endsWith(it.key) + }.flatMap { it.value } + } override fun add( uri: URI?, cookie: HttpCookie, ) { - @Suppress("NAME_SHADOWING") - val uri = uri ?: URI("http://" + cookie.domain.removePrefix(".")) - val url = uri.toURL() lock.withLock { - val cookies = cookieMap[url.host] - cookieMap[url.host] = cookies.orEmpty() + cookie.toCookie(uri) - saveToDisk(url) + val cookie = cookie.toCookie() + val cookiesForDomain = cookieMap[cookie.domain].orEmpty().toMutableList() + // Find a cookie with the same name. Replace it if found, otherwise add a new one. + val pos = cookiesForDomain.indexOfFirst { it.name == cookie.name } + if (pos == -1) { + cookiesForDomain.add(cookie) + } else { + cookiesForDomain[pos] = cookie + } + cookieMap[cookie.domain] = cookiesForDomain + saveToDisk(setOf(cookie.domain)) } } override fun getCookies(): List = - cookieMap.values.flatMap { - it.map { - it.toHttpCookie() + lock.withLock { + cookieMap.values.flatMap { + it.map { + it.toHttpCookie() + } } } + fun getStoredCookies(): List = + lock.withLock { + cookieMap.values.flatMap { it } + } + override fun getURIs(): List = - cookieMap.keys().toList().map { - URI("http://$it") + lock.withLock { + cookieMap.keys.toList().map { + URI("http://$it") + } } override fun remove( uri: URI?, cookie: HttpCookie, - ): Boolean { - @Suppress("NAME_SHADOWING") - val uri = uri ?: URI("http://" + cookie.domain.removePrefix(".")) - val url = uri.toURL() - return lock.withLock { - val cookies = cookieMap[url.host].orEmpty() + ): Boolean = + lock.withLock { + val cookie = cookie.toCookie() + val cookies = cookieMap[cookie.domain].orEmpty() val index = cookies.indexOfFirst { it.name == cookie.name && @@ -135,63 +163,73 @@ class PersistentCookieStore( if (index >= 0) { val newList = cookies.toMutableList() newList.removeAt(index) - cookieMap[url.host] = newList.toList() - saveToDisk(url) + cookieMap[cookie.domain] = newList.toList() + saveToDisk(setOf(cookie.domain)) true } else { false } } - } - private fun get(url: String): List = cookieMap[url].orEmpty().filter { !it.hasExpired() } - - private fun saveToDisk(url: URL) { + private fun saveToDisk(domains: Set) { // Get cookies to be stored in disk - val newValues = - cookieMap[url.host] - .orEmpty() - .asSequence() - .filter { it.persistent && !it.hasExpired() } - .map(Cookie::toString) - .toSet() - - prefs.edit().putStringSet(url.host, newValues).apply() + prefs + .edit() + .apply { + domains.forEach { domain -> + val newValues = + cookieMap[domain] + .orEmpty() + .onEach { println(it) } + .asSequence() + .filter { it.persistent && !it.hasExpired() } + .map(Cookie::toString) + .toSet() + if (newValues.isNotEmpty()) { + remove(domain) + putStringSet(domain, newValues) + } else { + remove(domain) + } + } + }.apply() } private fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt - private fun HttpCookie.toCookie(uri: URI) = + private fun HttpCookie.toCookie() = Cookie .Builder() .name(name) .value(value) - .domain(uri.toURL().host) + .domain(domain.removePrefix(".")) .path(path ?: "/") - .let { + .also { if (maxAge != -1L) { it.expiresAt(System.currentTimeMillis() + maxAge.seconds.inWholeMilliseconds) } else { it.expiresAt(Long.MAX_VALUE) } - }.let { if (secure) { it.secure() - } else { - it } - }.let { if (isHttpOnly) { it.httpOnly() - } else { - it + } + if (!domain.startsWith('.')) { + it.hostOnlyDomain(domain.removePrefix(".")) } }.build() private fun Cookie.toHttpCookie(): HttpCookie { val it = this return HttpCookie(it.name, it.value).apply { - domain = it.domain + domain = + if (hostOnly) { + it.domain + } else { + "." + it.domain + } path = it.path secure = it.secure maxAge = diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt index cfbe7bbf..66ba8c84 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/interceptor/CloudflareInterceptor.kt @@ -238,6 +238,7 @@ object CFClearance { if (!cookie.path.isNullOrEmpty()) it.path(cookie.path) // We need to convert the expires time to milliseconds for the persistent cookie store if (cookie.expires != null && cookie.expires > 0) it.expiresAt((cookie.expires * 1000).toLong()) + if (!cookie.domain.startsWith('.')) it.hostOnlyDomain(cookie.domain.removePrefix(".")) }.build() }.groupBy { it.domain } .flatMap { (domain, cookies) -> diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/GlobalAPI.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/GlobalAPI.kt index 174eb78f..170250d5 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/global/GlobalAPI.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/GlobalAPI.kt @@ -10,8 +10,10 @@ package suwayomi.tachidesk.global import io.javalin.apibuilder.ApiBuilder.get import io.javalin.apibuilder.ApiBuilder.patch import io.javalin.apibuilder.ApiBuilder.path +import io.javalin.apibuilder.ApiBuilder.ws import suwayomi.tachidesk.global.controller.GlobalMetaController import suwayomi.tachidesk.global.controller.SettingsController +import suwayomi.tachidesk.global.controller.WebViewController object GlobalAPI { fun defineEndpoints() { @@ -23,5 +25,9 @@ object GlobalAPI { get("about", SettingsController.about) get("check-update", SettingsController.checkUpdate) } + path("webview") { + get("", WebViewController.webview) + ws("", WebViewController::webviewWS) + } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/controller/WebViewController.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/controller/WebViewController.kt new file mode 100644 index 00000000..e29addea --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/controller/WebViewController.kt @@ -0,0 +1,38 @@ +package suwayomi.tachidesk.global.controller + +/* + * Copyright (C) Contributors to the Suwayomi project + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +import io.javalin.http.ContentType +import io.javalin.http.HttpStatus +import io.javalin.websocket.WsConfig +import suwayomi.tachidesk.global.impl.WebView +import suwayomi.tachidesk.server.util.handler +import suwayomi.tachidesk.server.util.withOperation + +object WebViewController { + val webview = + handler( + documentWith = { + withOperation { + summary("WebView") + description("Opens and browses WebView") + } + }, + behaviorOf = { ctx -> + ctx.contentType(ContentType.TEXT_HTML) + ctx.result(javaClass.getResourceAsStream("/webview.html")!!) + }, + withResults = { mime(HttpStatus.OK, "text/html") }, + ) + + fun webviewWS(ws: WsConfig) { + ws.onConnect { ctx -> WebView.addClient(ctx) } + ws.onMessage { ctx -> WebView.handleRequest(ctx) } + ws.onClose { ctx -> WebView.removeClient(ctx) } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/impl/KcefWebView.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/KcefWebView.kt new file mode 100644 index 00000000..e103ea3a --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/KcefWebView.kt @@ -0,0 +1,569 @@ +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.input.CefTouchEvent +import org.cef.network.CefCookie +import org.cef.network.CefCookieManager +import uy.kohesive.injekt.injectLazy +import java.awt.Component +import java.awt.Rectangle +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() + + 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.debug { "$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.info { "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) + } + } + + // 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 = + KCEF.newClientBlocking().apply { + addDisplayHandler(DisplayHandler()) + addLoadHandler(LoadHandler()) + } + + logger.info { "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.info { "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 + 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 (msg.key) { + "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 = + ( + (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 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.info { "Load finished with title $it" } + WebView.notifyAllClients( + Json.encodeToString( + LoadEvent(url, it ?: "", status, error), + ), + ) + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/global/impl/WebView.kt b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/WebView.kt new file mode 100644 index 00000000..897a1c45 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/global/impl/WebView.kt @@ -0,0 +1,105 @@ +package suwayomi.tachidesk.global.impl + +import io.github.oshai.kotlinlogging.KotlinLogging +import io.javalin.websocket.WsContext +import io.javalin.websocket.WsMessageContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.eclipse.jetty.websocket.api.CloseStatus +import suwayomi.tachidesk.manga.impl.update.Websocket + +object WebView : Websocket() { + private val logger = KotlinLogging.logger {} + private var driver: KcefWebView? = null + + override fun addClient(ctx: WsContext) { + if (clients.isNotEmpty()) { + // TODO: allow multiple concurrent accesses? + clients.forEach { it.value.closeSession(CloseStatus(1001, "Other client connected")) } + clients.clear() + } + if (driver == null) { + driver = KcefWebView() + } + super.addClient(ctx) + ctx.enableAutomaticPings() + } + + override fun removeClient(ctx: WsContext) { + super.removeClient(ctx) + if (clients.isEmpty()) { + driver?.destroy() + driver = null + } + } + + override fun notifyClient( + ctx: WsContext, + value: String?, + ) { + if (value != null) { + ctx.send(value) + } + } + + @Serializable public sealed class TypeObject + + @Serializable + @SerialName("loadUrl") + private data class LoadUrlMessage( + val url: String, + val width: Int, + val height: Int, + ) : TypeObject() + + @Serializable + @SerialName("resize") + private data class ResizeMessage( + val width: Int, + val height: Int, + ) : TypeObject() + + @Serializable + @SerialName("event") + public data class JsEventMessage( + val eventType: String, + val clickX: Float, + val clickY: Float, + val button: Int? = null, + val ctrlKey: Boolean? = null, + val shiftKey: Boolean? = null, + val altKey: Boolean? = null, + val metaKey: Boolean? = null, + val key: String? = null, + val code: String? = null, + val clientX: Float? = null, + val clientY: Float? = null, + val deltaY: Float? = null, + ) : TypeObject() + + override fun handleRequest(ctx: WsMessageContext) { + val dr = driver ?: return + try { + val event = Json.decodeFromString(ctx.message()) + when (event) { + is LoadUrlMessage -> { + val url = event.url + dr.loadUrl(url) + dr.resize(event.width, event.height) + logger.info { "Loading URL $url" } + } + is ResizeMessage -> { + dr.resize(event.width, event.height) + logger.info { "Resize browser" } + } + is JsEventMessage -> { + val type = event.eventType + dr.event(event) + } + } + } catch (e: Exception) { + logger.warn(e) { "Failed to deserialize client request: ${ctx.message()}" } + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt index dee78a99..d9b82d16 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/ServerSetup.kt @@ -27,9 +27,11 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.cef.network.CefCookieManager import org.koin.core.context.startKoin import org.koin.core.module.Module import org.koin.dsl.module +import suwayomi.tachidesk.global.impl.KcefWebView.Companion.toCefCookie import suwayomi.tachidesk.i18n.LocalizationHelper import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport import suwayomi.tachidesk.manga.impl.download.DownloadManager @@ -45,6 +47,7 @@ import uy.kohesive.injekt.api.get import xyz.nulldev.androidcompat.AndroidCompat import xyz.nulldev.androidcompat.AndroidCompatInitializer import xyz.nulldev.androidcompat.androidCompatModule +import xyz.nulldev.androidcompat.webkit.KcefWebViewProvider import xyz.nulldev.ts.config.ApplicationRootDir import xyz.nulldev.ts.config.BASE_LOGGER_NAME import xyz.nulldev.ts.config.GlobalConfigManager @@ -69,16 +72,21 @@ class ApplicationDirs( val tempRoot: String = "${System.getProperty("java.io.tmpdir")}/Tachidesk", ) { val extensionsRoot = "$dataRoot/extensions" - val downloadsRoot get() = serverConfig.downloadsPath.value.ifBlank { "$dataRoot/downloads" } - val localMangaRoot get() = serverConfig.localSourcePath.value.ifBlank { "$dataRoot/local" } + val downloadsRoot + get() = serverConfig.downloadsPath.value.ifBlank { "$dataRoot/downloads" } + val localMangaRoot + get() = serverConfig.localSourcePath.value.ifBlank { "$dataRoot/local" } val webUIRoot = "$dataRoot/webUI" - val automatedBackupRoot get() = serverConfig.backupPath.value.ifBlank { "$dataRoot/backups" } + val automatedBackupRoot + get() = serverConfig.backupPath.value.ifBlank { "$dataRoot/backups" } val tempThumbnailCacheRoot = "$tempRoot/thumbnails" val tempMangaCacheRoot = "$tempRoot/manga-cache" - val thumbnailDownloadsRoot get() = "$downloadsRoot/thumbnails" - val mangaDownloadsRoot get() = "$downloadsRoot/mangas" + val thumbnailDownloadsRoot + get() = "$downloadsRoot/thumbnails" + val mangaDownloadsRoot + get() = "$downloadsRoot/mangas" } @Suppress("DEPRECATION") @@ -108,9 +116,15 @@ fun setupLogLevelUpdating( loggerNames: List, defaultLevel: Level = Level.INFO, ) { - serverConfig.subscribeTo(configFlow, { debugLogsEnabled -> - loggerNames.forEach { loggerName -> setLogLevelFor(loggerName, if (debugLogsEnabled) Level.DEBUG else defaultLevel) } - }, ignoreInitialValue = false) + serverConfig.subscribeTo( + configFlow, + { debugLogsEnabled -> + loggerNames.forEach { loggerName -> + setLogLevelFor(loggerName, if (debugLogsEnabled) Level.DEBUG else defaultLevel) + } + }, + ignoreInitialValue = false, + ) } fun serverModule(applicationDirs: ApplicationDirs): Module = @@ -123,7 +137,7 @@ fun serverModule(applicationDirs: ApplicationDirs): Module = @OptIn(DelicateCoroutinesApi::class) fun applicationSetup() { Thread.setDefaultUncaughtExceptionHandler { _, throwable -> - KotlinLogging.logger { }.error(throwable) { "unhandled exception" } + KotlinLogging.logger {}.error(throwable) { "unhandled exception" } } val mainLoop = LooperThread() @@ -169,7 +183,10 @@ fun applicationSetup() { GlobalConfigManager.config .root() .render(ConfigRenderOptions.concise().setFormatted(true)) - .replace(Regex("(\"basicAuth(?:Username|Password)\"\\s:\\s)(?!\"\")\".*\""), "$1\"******\"") + .replace( + Regex("(\"basicAuth(?:Username|Password)\"\\s:\\s)(?!\"\")\".*\""), + "$1\"******\"", + ) } logger.debug { "Data Root directory is set to: ${applicationDirs.dataRoot}" } @@ -187,9 +204,7 @@ fun applicationSetup() { applicationDirs.tempThumbnailCacheRoot, applicationDirs.downloadsRoot, applicationDirs.localMangaRoot, - ).forEach { - File(it).mkdirs() - } + ).forEach { File(it).mkdirs() } // initialize Koin modules val app = App() @@ -199,6 +214,34 @@ fun applicationSetup() { androidCompatModule(), configManagerModule(), serverModule(applicationDirs), + module { + single { + object : KcefWebViewProvider.InitBrowserHandler { + override fun init(provider: KcefWebViewProvider) { + val networkHelper = Injekt.get() + val logger = KotlinLogging.logger {} + logger.info { "Start loading cookies" } + CefCookieManager.getGlobalManager().apply { + val cookies = networkHelper.cookieStore.getStoredCookies() + for (cookie in cookies) { + logger.info { "Loading cookie ${cookie.name} for ${cookie.domain}" } + try { + if (!setCookie( + "https://" + cookie.domain, + cookie.toCefCookie(), + ) + ) { + throw Exception() + } + } catch (e: Exception) { + logger.warn(e) { "Loading cookie ${cookie.name} failed" } + } + } + } + } + } + } + }, ) } @@ -214,18 +257,15 @@ fun applicationSetup() { Injekt .get() .userAgentFlow - .onEach { - System.setProperty("http.agent", it) - }.launchIn(GlobalScope) + .onEach { System.setProperty("http.agent", it) } + .launchIn(GlobalScope) // create or update conf file if doesn't exist try { val dataConfFile = File("${applicationDirs.dataRoot}/server.conf") if (!dataConfFile.exists()) { JavalinSetup::class.java.getResourceAsStream("/server-reference.conf").use { input -> - dataConfFile.outputStream().use { output -> - input.copyTo(output) - } + dataConfFile.outputStream().use { output -> input.copyTo(output) } } } else { // make sure the user config file is up-to-date @@ -240,39 +280,45 @@ fun applicationSetup() { val localSourceIconFile = File("${applicationDirs.extensionsRoot}/icon/localSource.png") if (!localSourceIconFile.exists()) { JavalinSetup::class.java.getResourceAsStream("/icon/localSource.png").use { input -> - localSourceIconFile.outputStream().use { output -> - input.copyTo(output) - } + localSourceIconFile.outputStream().use { output -> input.copyTo(output) } } } } catch (e: Exception) { logger.error(e) { "Exception while copying Local source's icon" } } - // fixes #119 , ref: https://github.com/Suwayomi/Suwayomi-Server/issues/119#issuecomment-894681292 , source Id calculation depends on String.lowercase() + // fixes #119 , ref: + // https://github.com/Suwayomi/Suwayomi-Server/issues/119#issuecomment-894681292 , source Id + // calculation depends on String.lowercase() Locale.setDefault(Locale.ENGLISH) // Initialize the localization service LocalizationHelper.initialize() - logger.debug { "Localization service initialized. Supported languages: ${LocalizationHelper.getSupportedLocales()}" } + logger.debug { + "Localization service initialized. Supported languages: ${LocalizationHelper.getSupportedLocales()}" + } databaseUp() LocalSource.register() // create system tray - serverConfig.subscribeTo(serverConfig.systemTrayEnabled, { systemTrayEnabled -> - try { - if (systemTrayEnabled) { - SystemTray.create() - } else { - SystemTray.remove() + serverConfig.subscribeTo( + serverConfig.systemTrayEnabled, + { systemTrayEnabled -> + try { + if (systemTrayEnabled) { + SystemTray.create() + } else { + SystemTray.remove() + } + } catch (e: Throwable) { + // cover both java.lang.Exception and java.lang.Error + logger.error(e) { "Failed to create/remove SystemTray due to" } } - } catch (e: Throwable) { - // cover both java.lang.Exception and java.lang.Error - logger.error(e) { "Failed to create/remove SystemTray due to" } - } - }, ignoreInitialValue = false) + }, + ignoreInitialValue = false, + ) runMigrations(applicationDirs) @@ -311,7 +357,10 @@ fun applicationSetup() { object : Authenticator() { override fun getPasswordAuthentication(): PasswordAuthentication? { if (requestingProtocol.startsWith("SOCKS", ignoreCase = true)) { - return PasswordAuthentication(proxyUsername, proxyPassword.toCharArray()) + return PasswordAuthentication( + proxyUsername, + proxyPassword.toCharArray(), + ) } return null @@ -357,13 +406,18 @@ fun applicationSetup() { } } download { github() } + settings { windowlessRenderingEnabled = true } + appHandler( + KCEF.AppHandler( + arrayOf("--disable-gpu", "--off-screen-rendering-enabled"), + ), + ) + val kcefDir = Path(applicationDirs.dataRoot) / "bin/kcef" kcefDir.createDirectories() installDir(kcefDir.toFile()) }, - onError = { - it?.printStackTrace() - }, + onError = { it?.printStackTrace() }, ) } } diff --git a/server/src/main/resources/webview.html b/server/src/main/resources/webview.html new file mode 100644 index 00000000..e8299d18 --- /dev/null +++ b/server/src/main/resources/webview.html @@ -0,0 +1,422 @@ + + + + + Suwayomi Webview + + + +
+

Suwayomi: WebView

+ +

Note: While focus is on the WebView part, no keybinds, including refresh, will be handled by the browser

+
+
+
Initializing... Please wait
+
+ + +
+ + +