mirror of
https://github.com/Suwayomi/Tachidesk.git
synced 2025-12-10 06:42:07 +01:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) ->
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
105
server/src/main/kotlin/suwayomi/tachidesk/global/impl/WebView.kt
Normal file
105
server/src/main/kotlin/suwayomi/tachidesk/global/impl/WebView.kt
Normal 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()}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
422
server/src/main/resources/webview.html
Normal file
422
server/src/main/resources/webview.html
Normal 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>
|
||||
Reference in New Issue
Block a user