Browser Webview (#1486)

* WebView: Add initial controller

Co-authored-by: Mitchell Syer <Syer10@users.noreply.github.com>

* 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 <Syer10@users.noreply.github.com>
This commit is contained in:
Constantin Piber
2025-07-01 23:28:41 +02:00
committed by GitHub
parent 8a62c6295d
commit a79dc580a5
11 changed files with 1361 additions and 118 deletions

View File

@@ -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<String>?,
): 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
}

View File

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

View File

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

View File

@@ -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<String, List<Cookie>>()
private val cookieMap = mutableMapOf<String, List<Cookie>>()
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<String>()
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<Cookie>,
) {
lock.withLock {
val domainsToSave = mutableSetOf<String>()
// 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<HttpCookie> {
val url = uri.toURL()
return get(url.host).map {
return get(url.toHttpUrlOrNull()!!).map {
it.toHttpCookie()
}
}
fun get(url: HttpUrl): List<Cookie> = get(url.host)
fun get(url: HttpUrl): List<Cookie> =
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<HttpCookie> =
cookieMap.values.flatMap {
it.map {
it.toHttpCookie()
lock.withLock {
cookieMap.values.flatMap {
it.map {
it.toHttpCookie()
}
}
}
fun getStoredCookies(): List<Cookie> =
lock.withLock {
cookieMap.values.flatMap { it }
}
override fun getURIs(): List<URI> =
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<Cookie> = cookieMap[url].orEmpty().filter { !it.hasExpired() }
private fun saveToDisk(url: URL) {
private fun saveToDisk(domains: Set<String>) {
// 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 =

View File

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

View File

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

View File

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

View File

@@ -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<Event>(
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<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.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<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())
}
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<Event>(
LoadEvent(url, it ?: "", status, error),
),
)
}
}
}

View File

@@ -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<String>() {
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<TypeObject>(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()}" }
}
}
}

View File

@@ -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<String>,
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<KcefWebViewProvider.InitBrowserHandler> {
object : KcefWebViewProvider.InitBrowserHandler {
override fun init(provider: KcefWebViewProvider) {
val networkHelper = Injekt.get<NetworkHelper>()
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<NetworkHelper>()
.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() },
)
}
}

View File

@@ -0,0 +1,422 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" />
<title>Suwayomi Webview</title>
<style>
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
}
body {
display: flex;
flex-direction: column;
}
body.disconnected::after {
content: 'Disconnected, please refresh';
position: absolute;
inset: 0;
background: rgba(150, 0, 0, 0.5);
color: white;
text-align: center;
align-content: center;
font-size: 2rem;
}
button[disabled], input[disabled] {
cursor: not-allowed;
}
header {
background-color: rgb(12, 16, 33);
color: #fff;
padding: 8px 32px;
}
header h1, header p {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
header nav {
display: flex;
flex-wrap: wrap;
column-gap: 20px;
align-items: center;
}
header form {
display: flex;
gap: 5px;
flex: auto 1 1;
min-width: 400px;
}
header label {
flex: auto 0 0;
cursor: pointer;
}
header button {
all: unset;
padding: 8px;
border-radius: 50%;
min-width: 1em;
line-height: 1;
text-align: center;
}
header button:not([disabled]) {
cursor: pointer;
}
header button:not([disabled]):hover {
background-color: rgba(255, 255, 255, 0.08);
}
header input {
flex: 100% 1 1;
}
main, iframe {
height: 100%;
}
main {
position: relative;
}
canvas, input#inputtrap {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
input#inputtrap {
opacity: 0;
padding: 0;
margin: 0;
border: none;
}
main .message, main .status {
position: relative;
z-index: 1;
}
main .message {
padding: 8px;
max-width: 1100px;
margin: auto;
font-style: italic;
}
main .message.error {
color: red;
font-style: regular;
font-weight: bold;
}
main .message:empty {
display: none;
}
main .status {
position: absolute;
bottom: 0;
left: 0;
max-width: 50%;
background: #555;
color: white;
padding: 2px 4px;
font-size: 0.8rem;
border-top-right-radius: 3px;
}
main .status:empty {
display: none;
}
/* https://css-tricks.com/snippets/css/css-triangle/ */
.arrow-right {
display: inline-block;
width: 0;
height: 0;
border-top: 9px solid transparent;
border-bottom: 9px solid transparent;
border-left: 9px solid currentcolor;
}
</style>
</head>
<body>
<header>
<h1 id="title">Suwayomi: WebView</h1>
<nav>
<form id="browseForm">
<input type="text" id="url" name="url" placeholder="Enter URL..." disabled/>
<button type="submit" id="goButton" disabled><span class="arrow-right"></span></button>
</form>
<label><input type="checkbox" id="reverseScroll" disabled/> Reverse Scrolling</label>
</nav>
<p><i>Note: While focus is on the WebView part, no keybinds, including refresh, will be handled by the browser</i></p>
</header>
<main>
<div class="message" id="message">Initializing... Please wait</div>
<div class="status" id="status"></div>
<canvas id="frame"></canvas>
<input type="text" id="inputtrap" autocomplete="off"/>
</main>
<script>
const messageDiv = document.getElementById('message');
const statusDiv = document.getElementById('status');
const frame = document.getElementById('frame');
const frameInput = document.getElementById('inputtrap');
const ctx = frame.getContext("2d");
const browseForm = document.getElementById('browseForm');
const goButton = document.getElementById('goButton');
const urlInput = document.getElementById('url');
const titleDiv = document.getElementById('title');
const reverseToggle = document.getElementById('reverseScroll');
try {
const socketUrl = (window.location.origin + window.location.pathname).replace(/^http/,'ws');
const socket = new WebSocket(socketUrl);
urlInput.disabled = false;
goButton.disabled = false;
reverseToggle.disabled = false;
reverseToggle.checked = window.localStorage.getItem('suwayomi_mouse_reverse_scroll') === "true";
let url = '';
try {
url = window.decodeURIComponent(window.location.hash.replace(/^#/, ''));
} catch (e) {
console.error(e);
}
/// Helpers
const setHash = (u) => {
let current = '';
try {
current = window.decodeURIComponent(window.location.hash.replace(/^#/, ''));
} catch (e) {
console.error(e);
}
if (current != u)
history.pushState(null, null, window.location.origin + window.location.pathname + '#' + window.encodeURIComponent(u));
};
const setTitle = (title) => {
if (!title) {
document.title = "Suwayomi Webview";
titleDiv.textContent = "Suwayomi Webview";
} else {
document.title = "Suwayomi: " + title;
titleDiv.textContent = "Suwayomi: " + title;
}
}
const loadUrl = (u) => {
if (!u) {
urlInput.value = u;
setHash(u);
setTitle();
messageDiv.textContent = 'Enter a URL to get started';
ctx.clearRect(0, 0, frame.width, frame.height);
return;
}
messageDiv.textContent = "Loading page...";
messageDiv.classList.remove('error');
urlInput.value = u;
socket.send(JSON.stringify({ type: 'loadUrl', url: u, width: frame.clientWidth, height: frame.clientHeight }));
ctx.clearRect(0, 0, frame.width, frame.height);
};
/// Form
window.addEventListener('hashchange', e => {
const url = window.decodeURIComponent(window.location.hash.replace(/^#/, ''));
loadUrl(url);
console.log('Navigate to', url);
});
browseForm.addEventListener('submit', e => {
e.preventDefault();
const url = urlInput.value;
loadUrl(url);
console.log('Navigate to', url);
});
reverseToggle.addEventListener('change', e => {
window.localStorage.setItem('suwayomi_mouse_reverse_scroll', e.target.checked ? "true" : "false");
});
/// Server events
socket.addEventListener('open', () => {
loadUrl(url);
console.log('WebSocket connection opened');
});
socket.addEventListener('message', e => {
const obj = JSON.parse(e.data);
switch (obj.type) {
case "addressChange":
console.log('Loaded');
messageDiv.textContent = '';
urlInput.value = obj.url;
setHash(obj.url);
setTitle(obj.title);
break;
case "statusChange":
statusDiv.textContent = obj.message;
break;
case "load": {
if (obj.error) {
messageDiv.textContent = "Error: " + obj.error;
messageDiv.classList.add('error');
} else {
messageDiv.textContent = "";
}
urlInput.value = obj.url;
setTitle(obj.title);
} break;
case "render": {
const img = new Image();
const imgData = new Blob([new Uint8Array(obj.image)], { type: "image/png" });
const url = URL.createObjectURL(imgData);
img.addEventListener('load', e => {
frame.width = img.width;
frame.height = img.height;
ctx.drawImage(img, 0, 0);
});
img.src = url;
} break;
case "consoleMessage": {
const lg = obj.severity == 4 ? console.error : obj.severity == 3 ? console.warn : console.log;
lg(`${obj.source}:${obj.line}:`, obj.message);
} break;
default:
console.warn("Unknown event", obj.type)
break;
}
});
socket.addEventListener('close', e => {
if (e.wasClean) {
console.log(`WebSocket connection closed cleanly, code=${e.code}, reason=${e.reason}`);
} else {
console.error('WebSocket connection died');
}
document.body.classList.add('disconnected');
});
socket.addEventListener('error', e => {
messageDiv.textContent = "Error: " + (e.message || e.reason || e);
messageDiv.classList.add('error');
console.error('WebSocket error:', e);
});
/// Page events
const observer = new ResizeObserver(() => {
socket.send(JSON.stringify({ type: 'resize', width: frame.clientWidth, height: frame.clientHeight }));
});
observer.observe(frame);
const frameEvent = (e) => {
// Chrome Android bug, see below
if (e.key === "Unidentified") return;
e.preventDefault();
const rect = frame.getBoundingClientRect();
const clickX = e.clientX !== undefined ? e.clientX - rect.left : 0;
const clickY = e.clientY !== undefined ? e.clientY - rect.top : 0;
socket.send(JSON.stringify({
type: 'event',
eventType: e.type,
clickX,
clickY,
button: e.button,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
altKey: e.altKey,
metaKey: e.metaKey,
key: e.key,
clientX: e.clientX,
clientY: e.clientY,
deltaY: reverseToggle.checked && typeof e.deltaY === 'number' ? -e.deltaY : e.deltaY,
}));
frameInput.focus();
};
const attachEvents = () => {
console.log('Attaching event handlers to new document');
const events = ["click", "mousedown", "mouseup", "mousemove", "wheel", "keydown", "keyup"];
for (const ev of events) {
frameInput.addEventListener(ev, frameEvent, false);
}
let touch = undefined;
frameInput.addEventListener('touchstart', e => {
if (e.touches.length === 1) {
touch = e.touches[0];
}
}, false);
frameInput.addEventListener('touchend', e => {
touch = undefined;
}, false);
frameInput.addEventListener('touchmove', e => {
if (e.touches.length === 1 && touch !== undefined) {
e.preventDefault();
let deltaX = touch.pageX - e.touches[0].pageX;
let deltaY = touch.pageY - e.touches[0].pageY;
console.log(deltaX, deltaY)
if (Math.abs(deltaX) > Math.abs(deltaY)) {
// assume horizontal scroll
socket.send(JSON.stringify({
type: 'event',
eventType: 'wheel',
clickX: e.touches[0].pageX,
clickY: e.touches[0].pageY,
shiftKey: true,
clientX: e.touches[0].clientX,
clientY: e.touches[0].clientY,
deltaY: deltaX,
}));
} else {
socket.send(JSON.stringify({
type: 'event',
eventType: 'wheel',
clickX: e.touches[0].pageX,
clickY: e.touches[0].pageY,
clientX: e.touches[0].clientX,
clientY: e.touches[0].clientY,
deltaY: deltaY,
}));
}
touch = e.touches[0];
}
}, false);
// known bug on Chrome Android:
// https://stackoverflow.com/questions/36753548/keycode-on-android-is-always-229
// on other browsers, the preventDefault above works so we don't get this event
frameInput.addEventListener('input', e => {
e.preventDefault();
socket.send(JSON.stringify({
type: 'event',
eventType: 'keydown',
clickX: 0,
clickY: 0,
key: e.data,
}));
socket.send(JSON.stringify({
type: 'event',
eventType: 'keyup',
clickX: 0,
clickY: 0,
key: e.data,
}));
e.target.value = '';
});
frameInput.addEventListener('contextmenu', e => {
e.preventDefault();
}, false);
};
attachEvents();
frameInput.focus();
} catch (e) {
messageDiv.textContent = "Error: " + (e.message || e);
messageDiv.classList.add('error');
console.error(e);
}
</script>
</body>
</html>