mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-04 03:14:40 -05: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 {
|
} else {
|
||||||
preferences.decodeValueOrNull(SetSerializer(String.serializer()), key)
|
preferences.decodeValueOrNull(SetSerializer(String.serializer()), key)
|
||||||
}
|
}
|
||||||
} catch (e: SerializationException) {
|
} catch (_: SerializationException) {
|
||||||
throw ClassCastException("$key was not a StringSet")
|
throw ClassCastException("$key was not a StringSet")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,11 +153,12 @@ class JavaSharedPreferences(
|
|||||||
key: String,
|
key: String,
|
||||||
value: String?,
|
value: String?,
|
||||||
): SharedPreferences.Editor {
|
): SharedPreferences.Editor {
|
||||||
if (value != null) {
|
actions +=
|
||||||
actions += Action.Add(key, value)
|
if (value != null) {
|
||||||
} else {
|
Action.Add(key, value)
|
||||||
actions += Action.Remove(key)
|
} else {
|
||||||
}
|
Action.Remove(key)
|
||||||
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,11 +166,12 @@ class JavaSharedPreferences(
|
|||||||
key: String,
|
key: String,
|
||||||
values: MutableSet<String>?,
|
values: MutableSet<String>?,
|
||||||
): SharedPreferences.Editor {
|
): SharedPreferences.Editor {
|
||||||
if (values != null) {
|
actions +=
|
||||||
actions += Action.Add(key, values)
|
if (values != null) {
|
||||||
} else {
|
Action.Add(key, values)
|
||||||
actions += Action.Remove(key)
|
} else {
|
||||||
}
|
Action.Remove(key)
|
||||||
|
}
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import org.cef.network.CefPostData
|
|||||||
import org.cef.network.CefPostDataElement
|
import org.cef.network.CefPostDataElement
|
||||||
import org.cef.network.CefRequest
|
import org.cef.network.CefRequest
|
||||||
import org.cef.network.CefResponse
|
import org.cef.network.CefResponse
|
||||||
|
import org.koin.mp.KoinPlatformTools
|
||||||
import java.io.BufferedWriter
|
import java.io.BufferedWriter
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -110,6 +111,12 @@ class KcefWebViewProvider(
|
|||||||
const val TAG = "KcefWebViewProvider"
|
const val TAG = "KcefWebViewProvider"
|
||||||
const val QUERY_FN = "__\$_suwayomiQuery"
|
const val QUERY_FN = "__\$_suwayomiQuery"
|
||||||
const val QUERY_CANCEL_FN = "__\$_suwayomiQueryCancel"
|
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(
|
private class CefWebResourceRequest(
|
||||||
@@ -451,6 +458,7 @@ class KcefWebViewProvider(
|
|||||||
config.jsCancelFunction = QUERY_CANCEL_FN
|
config.jsCancelFunction = QUERY_CANCEL_FN
|
||||||
addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler()))
|
addMessageRouter(CefMessageRouter.create(config, MessageRouterHandler()))
|
||||||
}
|
}
|
||||||
|
initHandler.init(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deprecated - should never be called
|
// Deprecated - should never be called
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ import okhttp3.OkHttpClient
|
|||||||
import okhttp3.brotli.BrotliInterceptor
|
import okhttp3.brotli.BrotliInterceptor
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
|
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
|
||||||
import java.io.File
|
|
||||||
import java.net.CookieHandler
|
import java.net.CookieHandler
|
||||||
import java.net.CookieManager
|
import java.net.CookieManager
|
||||||
import java.net.CookiePolicy
|
import java.net.CookiePolicy
|
||||||
|
import java.nio.file.Files
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class NetworkHelper(
|
class NetworkHelper(
|
||||||
@@ -79,7 +79,7 @@ class NetworkHelper(
|
|||||||
.callTimeout(2, TimeUnit.MINUTES)
|
.callTimeout(2, TimeUnit.MINUTES)
|
||||||
.cache(
|
.cache(
|
||||||
Cache(
|
Cache(
|
||||||
directory = File.createTempFile("tachidesk_network_cache", null),
|
directory = Files.createTempDirectory("tachidesk_network_cache").toFile(),
|
||||||
maxSize = 5L * 1024 * 1024, // 5 MiB
|
maxSize = 5L * 1024 * 1024, // 5 MiB
|
||||||
),
|
),
|
||||||
).addInterceptor(UncaughtExceptionInterceptor())
|
).addInterceptor(UncaughtExceptionInterceptor())
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import okio.withLock
|
|||||||
import java.net.CookieStore
|
import java.net.CookieStore
|
||||||
import java.net.HttpCookie
|
import java.net.HttpCookie
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.net.URL
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
@@ -18,30 +16,40 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
class PersistentCookieStore(
|
class PersistentCookieStore(
|
||||||
context: Context,
|
context: Context,
|
||||||
) : CookieStore {
|
) : 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 prefs = context.getSharedPreferences("cookie_store", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
private val lock = ReentrantLock()
|
private val lock = ReentrantLock()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val domains =
|
lock.withLock {
|
||||||
prefs.all.keys
|
val domains =
|
||||||
.map { it.substringBeforeLast(".") }
|
prefs.all.keys
|
||||||
.toSet()
|
.map { it.substringBeforeLast(".") }
|
||||||
domains.forEach { domain ->
|
.toSet()
|
||||||
val cookies = prefs.getStringSet(domain, emptySet())
|
val domainsToSave = mutableSetOf<String>()
|
||||||
if (!cookies.isNullOrEmpty()) {
|
domains.forEach { domain ->
|
||||||
try {
|
val cookies = prefs.getStringSet(domain, emptySet())
|
||||||
val url = "http://$domain".toHttpUrlOrNull() ?: return@forEach
|
if (!cookies.isNullOrEmpty()) {
|
||||||
val nonExpiredCookies =
|
try {
|
||||||
cookies
|
val url = "http://$domain".toHttpUrlOrNull() ?: return@forEach
|
||||||
.mapNotNull { Cookie.parse(url, it) }
|
val nonExpiredCookies =
|
||||||
.filter { !it.hasExpired() }
|
cookies
|
||||||
cookieMap[domain] = nonExpiredCookies
|
.mapNotNull { Cookie.parse(url, it) }
|
||||||
} catch (e: Exception) {
|
.filter { !it.hasExpired() }
|
||||||
// Ignore
|
.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>,
|
cookies: List<Cookie>,
|
||||||
) {
|
) {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
|
val domainsToSave = mutableSetOf<String>()
|
||||||
// Append or replace the cookies for this domain.
|
// Append or replace the cookies for this domain.
|
||||||
val cookiesForDomain = cookieMap[url.host].orEmpty().toMutableList()
|
|
||||||
for (cookie in cookies) {
|
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.
|
// Find a cookie with the same name. Replace it if found, otherwise add a new one.
|
||||||
val pos = cookiesForDomain.indexOfFirst { it.name == cookie.name }
|
val pos = cookiesForDomain.indexOfFirst { it.name == cookie.name }
|
||||||
if (pos == -1) {
|
if (pos == -1) {
|
||||||
@@ -60,10 +69,11 @@ class PersistentCookieStore(
|
|||||||
} else {
|
} else {
|
||||||
cookiesForDomain[pos] = cookie
|
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> {
|
override fun get(uri: URI): List<HttpCookie> {
|
||||||
val url = uri.toURL()
|
val url = uri.toURL()
|
||||||
return get(url.host).map {
|
return get(url.toHttpUrlOrNull()!!).map {
|
||||||
it.toHttpCookie()
|
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(
|
override fun add(
|
||||||
uri: URI?,
|
uri: URI?,
|
||||||
cookie: HttpCookie,
|
cookie: HttpCookie,
|
||||||
) {
|
) {
|
||||||
@Suppress("NAME_SHADOWING")
|
|
||||||
val uri = uri ?: URI("http://" + cookie.domain.removePrefix("."))
|
|
||||||
val url = uri.toURL()
|
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
val cookies = cookieMap[url.host]
|
val cookie = cookie.toCookie()
|
||||||
cookieMap[url.host] = cookies.orEmpty() + cookie.toCookie(uri)
|
val cookiesForDomain = cookieMap[cookie.domain].orEmpty().toMutableList()
|
||||||
saveToDisk(url)
|
// 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> =
|
override fun getCookies(): List<HttpCookie> =
|
||||||
cookieMap.values.flatMap {
|
lock.withLock {
|
||||||
it.map {
|
cookieMap.values.flatMap {
|
||||||
it.toHttpCookie()
|
it.map {
|
||||||
|
it.toHttpCookie()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getStoredCookies(): List<Cookie> =
|
||||||
|
lock.withLock {
|
||||||
|
cookieMap.values.flatMap { it }
|
||||||
|
}
|
||||||
|
|
||||||
override fun getURIs(): List<URI> =
|
override fun getURIs(): List<URI> =
|
||||||
cookieMap.keys().toList().map {
|
lock.withLock {
|
||||||
URI("http://$it")
|
cookieMap.keys.toList().map {
|
||||||
|
URI("http://$it")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun remove(
|
override fun remove(
|
||||||
uri: URI?,
|
uri: URI?,
|
||||||
cookie: HttpCookie,
|
cookie: HttpCookie,
|
||||||
): Boolean {
|
): Boolean =
|
||||||
@Suppress("NAME_SHADOWING")
|
lock.withLock {
|
||||||
val uri = uri ?: URI("http://" + cookie.domain.removePrefix("."))
|
val cookie = cookie.toCookie()
|
||||||
val url = uri.toURL()
|
val cookies = cookieMap[cookie.domain].orEmpty()
|
||||||
return lock.withLock {
|
|
||||||
val cookies = cookieMap[url.host].orEmpty()
|
|
||||||
val index =
|
val index =
|
||||||
cookies.indexOfFirst {
|
cookies.indexOfFirst {
|
||||||
it.name == cookie.name &&
|
it.name == cookie.name &&
|
||||||
@@ -135,63 +163,73 @@ class PersistentCookieStore(
|
|||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
val newList = cookies.toMutableList()
|
val newList = cookies.toMutableList()
|
||||||
newList.removeAt(index)
|
newList.removeAt(index)
|
||||||
cookieMap[url.host] = newList.toList()
|
cookieMap[cookie.domain] = newList.toList()
|
||||||
saveToDisk(url)
|
saveToDisk(setOf(cookie.domain))
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun get(url: String): List<Cookie> = cookieMap[url].orEmpty().filter { !it.hasExpired() }
|
private fun saveToDisk(domains: Set<String>) {
|
||||||
|
|
||||||
private fun saveToDisk(url: URL) {
|
|
||||||
// Get cookies to be stored in disk
|
// Get cookies to be stored in disk
|
||||||
val newValues =
|
prefs
|
||||||
cookieMap[url.host]
|
.edit()
|
||||||
.orEmpty()
|
.apply {
|
||||||
.asSequence()
|
domains.forEach { domain ->
|
||||||
.filter { it.persistent && !it.hasExpired() }
|
val newValues =
|
||||||
.map(Cookie::toString)
|
cookieMap[domain]
|
||||||
.toSet()
|
.orEmpty()
|
||||||
|
.onEach { println(it) }
|
||||||
prefs.edit().putStringSet(url.host, newValues).apply()
|
.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 Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt
|
||||||
|
|
||||||
private fun HttpCookie.toCookie(uri: URI) =
|
private fun HttpCookie.toCookie() =
|
||||||
Cookie
|
Cookie
|
||||||
.Builder()
|
.Builder()
|
||||||
.name(name)
|
.name(name)
|
||||||
.value(value)
|
.value(value)
|
||||||
.domain(uri.toURL().host)
|
.domain(domain.removePrefix("."))
|
||||||
.path(path ?: "/")
|
.path(path ?: "/")
|
||||||
.let {
|
.also {
|
||||||
if (maxAge != -1L) {
|
if (maxAge != -1L) {
|
||||||
it.expiresAt(System.currentTimeMillis() + maxAge.seconds.inWholeMilliseconds)
|
it.expiresAt(System.currentTimeMillis() + maxAge.seconds.inWholeMilliseconds)
|
||||||
} else {
|
} else {
|
||||||
it.expiresAt(Long.MAX_VALUE)
|
it.expiresAt(Long.MAX_VALUE)
|
||||||
}
|
}
|
||||||
}.let {
|
|
||||||
if (secure) {
|
if (secure) {
|
||||||
it.secure()
|
it.secure()
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
}
|
||||||
}.let {
|
|
||||||
if (isHttpOnly) {
|
if (isHttpOnly) {
|
||||||
it.httpOnly()
|
it.httpOnly()
|
||||||
} else {
|
}
|
||||||
it
|
if (!domain.startsWith('.')) {
|
||||||
|
it.hostOnlyDomain(domain.removePrefix("."))
|
||||||
}
|
}
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
private fun Cookie.toHttpCookie(): HttpCookie {
|
private fun Cookie.toHttpCookie(): HttpCookie {
|
||||||
val it = this
|
val it = this
|
||||||
return HttpCookie(it.name, it.value).apply {
|
return HttpCookie(it.name, it.value).apply {
|
||||||
domain = it.domain
|
domain =
|
||||||
|
if (hostOnly) {
|
||||||
|
it.domain
|
||||||
|
} else {
|
||||||
|
"." + it.domain
|
||||||
|
}
|
||||||
path = it.path
|
path = it.path
|
||||||
secure = it.secure
|
secure = it.secure
|
||||||
maxAge =
|
maxAge =
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ object CFClearance {
|
|||||||
if (!cookie.path.isNullOrEmpty()) it.path(cookie.path)
|
if (!cookie.path.isNullOrEmpty()) it.path(cookie.path)
|
||||||
// We need to convert the expires time to milliseconds for the persistent cookie store
|
// 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.expires != null && cookie.expires > 0) it.expiresAt((cookie.expires * 1000).toLong())
|
||||||
|
if (!cookie.domain.startsWith('.')) it.hostOnlyDomain(cookie.domain.removePrefix("."))
|
||||||
}.build()
|
}.build()
|
||||||
}.groupBy { it.domain }
|
}.groupBy { it.domain }
|
||||||
.flatMap { (domain, cookies) ->
|
.flatMap { (domain, cookies) ->
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ package suwayomi.tachidesk.global
|
|||||||
import io.javalin.apibuilder.ApiBuilder.get
|
import io.javalin.apibuilder.ApiBuilder.get
|
||||||
import io.javalin.apibuilder.ApiBuilder.patch
|
import io.javalin.apibuilder.ApiBuilder.patch
|
||||||
import io.javalin.apibuilder.ApiBuilder.path
|
import io.javalin.apibuilder.ApiBuilder.path
|
||||||
|
import io.javalin.apibuilder.ApiBuilder.ws
|
||||||
import suwayomi.tachidesk.global.controller.GlobalMetaController
|
import suwayomi.tachidesk.global.controller.GlobalMetaController
|
||||||
import suwayomi.tachidesk.global.controller.SettingsController
|
import suwayomi.tachidesk.global.controller.SettingsController
|
||||||
|
import suwayomi.tachidesk.global.controller.WebViewController
|
||||||
|
|
||||||
object GlobalAPI {
|
object GlobalAPI {
|
||||||
fun defineEndpoints() {
|
fun defineEndpoints() {
|
||||||
@@ -23,5 +25,9 @@ object GlobalAPI {
|
|||||||
get("about", SettingsController.about)
|
get("about", SettingsController.about)
|
||||||
get("check-update", SettingsController.checkUpdate)
|
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.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
|
import org.cef.network.CefCookieManager
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
import org.koin.core.module.Module
|
import org.koin.core.module.Module
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
import suwayomi.tachidesk.global.impl.KcefWebView.Companion.toCefCookie
|
||||||
import suwayomi.tachidesk.i18n.LocalizationHelper
|
import suwayomi.tachidesk.i18n.LocalizationHelper
|
||||||
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport
|
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport
|
||||||
import suwayomi.tachidesk.manga.impl.download.DownloadManager
|
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.AndroidCompat
|
||||||
import xyz.nulldev.androidcompat.AndroidCompatInitializer
|
import xyz.nulldev.androidcompat.AndroidCompatInitializer
|
||||||
import xyz.nulldev.androidcompat.androidCompatModule
|
import xyz.nulldev.androidcompat.androidCompatModule
|
||||||
|
import xyz.nulldev.androidcompat.webkit.KcefWebViewProvider
|
||||||
import xyz.nulldev.ts.config.ApplicationRootDir
|
import xyz.nulldev.ts.config.ApplicationRootDir
|
||||||
import xyz.nulldev.ts.config.BASE_LOGGER_NAME
|
import xyz.nulldev.ts.config.BASE_LOGGER_NAME
|
||||||
import xyz.nulldev.ts.config.GlobalConfigManager
|
import xyz.nulldev.ts.config.GlobalConfigManager
|
||||||
@@ -69,16 +72,21 @@ class ApplicationDirs(
|
|||||||
val tempRoot: String = "${System.getProperty("java.io.tmpdir")}/Tachidesk",
|
val tempRoot: String = "${System.getProperty("java.io.tmpdir")}/Tachidesk",
|
||||||
) {
|
) {
|
||||||
val extensionsRoot = "$dataRoot/extensions"
|
val extensionsRoot = "$dataRoot/extensions"
|
||||||
val downloadsRoot get() = serverConfig.downloadsPath.value.ifBlank { "$dataRoot/downloads" }
|
val downloadsRoot
|
||||||
val localMangaRoot get() = serverConfig.localSourcePath.value.ifBlank { "$dataRoot/local" }
|
get() = serverConfig.downloadsPath.value.ifBlank { "$dataRoot/downloads" }
|
||||||
|
val localMangaRoot
|
||||||
|
get() = serverConfig.localSourcePath.value.ifBlank { "$dataRoot/local" }
|
||||||
val webUIRoot = "$dataRoot/webUI"
|
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 tempThumbnailCacheRoot = "$tempRoot/thumbnails"
|
||||||
val tempMangaCacheRoot = "$tempRoot/manga-cache"
|
val tempMangaCacheRoot = "$tempRoot/manga-cache"
|
||||||
|
|
||||||
val thumbnailDownloadsRoot get() = "$downloadsRoot/thumbnails"
|
val thumbnailDownloadsRoot
|
||||||
val mangaDownloadsRoot get() = "$downloadsRoot/mangas"
|
get() = "$downloadsRoot/thumbnails"
|
||||||
|
val mangaDownloadsRoot
|
||||||
|
get() = "$downloadsRoot/mangas"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
@@ -108,9 +116,15 @@ fun setupLogLevelUpdating(
|
|||||||
loggerNames: List<String>,
|
loggerNames: List<String>,
|
||||||
defaultLevel: Level = Level.INFO,
|
defaultLevel: Level = Level.INFO,
|
||||||
) {
|
) {
|
||||||
serverConfig.subscribeTo(configFlow, { debugLogsEnabled ->
|
serverConfig.subscribeTo(
|
||||||
loggerNames.forEach { loggerName -> setLogLevelFor(loggerName, if (debugLogsEnabled) Level.DEBUG else defaultLevel) }
|
configFlow,
|
||||||
}, ignoreInitialValue = false)
|
{ debugLogsEnabled ->
|
||||||
|
loggerNames.forEach { loggerName ->
|
||||||
|
setLogLevelFor(loggerName, if (debugLogsEnabled) Level.DEBUG else defaultLevel)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ignoreInitialValue = false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun serverModule(applicationDirs: ApplicationDirs): Module =
|
fun serverModule(applicationDirs: ApplicationDirs): Module =
|
||||||
@@ -123,7 +137,7 @@ fun serverModule(applicationDirs: ApplicationDirs): Module =
|
|||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
fun applicationSetup() {
|
fun applicationSetup() {
|
||||||
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
|
||||||
KotlinLogging.logger { }.error(throwable) { "unhandled exception" }
|
KotlinLogging.logger {}.error(throwable) { "unhandled exception" }
|
||||||
}
|
}
|
||||||
|
|
||||||
val mainLoop = LooperThread()
|
val mainLoop = LooperThread()
|
||||||
@@ -169,7 +183,10 @@ fun applicationSetup() {
|
|||||||
GlobalConfigManager.config
|
GlobalConfigManager.config
|
||||||
.root()
|
.root()
|
||||||
.render(ConfigRenderOptions.concise().setFormatted(true))
|
.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}" }
|
logger.debug { "Data Root directory is set to: ${applicationDirs.dataRoot}" }
|
||||||
@@ -187,9 +204,7 @@ fun applicationSetup() {
|
|||||||
applicationDirs.tempThumbnailCacheRoot,
|
applicationDirs.tempThumbnailCacheRoot,
|
||||||
applicationDirs.downloadsRoot,
|
applicationDirs.downloadsRoot,
|
||||||
applicationDirs.localMangaRoot,
|
applicationDirs.localMangaRoot,
|
||||||
).forEach {
|
).forEach { File(it).mkdirs() }
|
||||||
File(it).mkdirs()
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialize Koin modules
|
// initialize Koin modules
|
||||||
val app = App()
|
val app = App()
|
||||||
@@ -199,6 +214,34 @@ fun applicationSetup() {
|
|||||||
androidCompatModule(),
|
androidCompatModule(),
|
||||||
configManagerModule(),
|
configManagerModule(),
|
||||||
serverModule(applicationDirs),
|
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
|
Injekt
|
||||||
.get<NetworkHelper>()
|
.get<NetworkHelper>()
|
||||||
.userAgentFlow
|
.userAgentFlow
|
||||||
.onEach {
|
.onEach { System.setProperty("http.agent", it) }
|
||||||
System.setProperty("http.agent", it)
|
.launchIn(GlobalScope)
|
||||||
}.launchIn(GlobalScope)
|
|
||||||
|
|
||||||
// create or update conf file if doesn't exist
|
// create or update conf file if doesn't exist
|
||||||
try {
|
try {
|
||||||
val dataConfFile = File("${applicationDirs.dataRoot}/server.conf")
|
val dataConfFile = File("${applicationDirs.dataRoot}/server.conf")
|
||||||
if (!dataConfFile.exists()) {
|
if (!dataConfFile.exists()) {
|
||||||
JavalinSetup::class.java.getResourceAsStream("/server-reference.conf").use { input ->
|
JavalinSetup::class.java.getResourceAsStream("/server-reference.conf").use { input ->
|
||||||
dataConfFile.outputStream().use { output ->
|
dataConfFile.outputStream().use { output -> input.copyTo(output) }
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// make sure the user config file is up-to-date
|
// make sure the user config file is up-to-date
|
||||||
@@ -240,39 +280,45 @@ fun applicationSetup() {
|
|||||||
val localSourceIconFile = File("${applicationDirs.extensionsRoot}/icon/localSource.png")
|
val localSourceIconFile = File("${applicationDirs.extensionsRoot}/icon/localSource.png")
|
||||||
if (!localSourceIconFile.exists()) {
|
if (!localSourceIconFile.exists()) {
|
||||||
JavalinSetup::class.java.getResourceAsStream("/icon/localSource.png").use { input ->
|
JavalinSetup::class.java.getResourceAsStream("/icon/localSource.png").use { input ->
|
||||||
localSourceIconFile.outputStream().use { output ->
|
localSourceIconFile.outputStream().use { output -> input.copyTo(output) }
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error(e) { "Exception while copying Local source's icon" }
|
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)
|
Locale.setDefault(Locale.ENGLISH)
|
||||||
|
|
||||||
// Initialize the localization service
|
// Initialize the localization service
|
||||||
LocalizationHelper.initialize()
|
LocalizationHelper.initialize()
|
||||||
logger.debug { "Localization service initialized. Supported languages: ${LocalizationHelper.getSupportedLocales()}" }
|
logger.debug {
|
||||||
|
"Localization service initialized. Supported languages: ${LocalizationHelper.getSupportedLocales()}"
|
||||||
|
}
|
||||||
|
|
||||||
databaseUp()
|
databaseUp()
|
||||||
|
|
||||||
LocalSource.register()
|
LocalSource.register()
|
||||||
|
|
||||||
// create system tray
|
// create system tray
|
||||||
serverConfig.subscribeTo(serverConfig.systemTrayEnabled, { systemTrayEnabled ->
|
serverConfig.subscribeTo(
|
||||||
try {
|
serverConfig.systemTrayEnabled,
|
||||||
if (systemTrayEnabled) {
|
{ systemTrayEnabled ->
|
||||||
SystemTray.create()
|
try {
|
||||||
} else {
|
if (systemTrayEnabled) {
|
||||||
SystemTray.remove()
|
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
|
ignoreInitialValue = false,
|
||||||
logger.error(e) { "Failed to create/remove SystemTray due to" }
|
)
|
||||||
}
|
|
||||||
}, ignoreInitialValue = false)
|
|
||||||
|
|
||||||
runMigrations(applicationDirs)
|
runMigrations(applicationDirs)
|
||||||
|
|
||||||
@@ -311,7 +357,10 @@ fun applicationSetup() {
|
|||||||
object : Authenticator() {
|
object : Authenticator() {
|
||||||
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
||||||
if (requestingProtocol.startsWith("SOCKS", ignoreCase = true)) {
|
if (requestingProtocol.startsWith("SOCKS", ignoreCase = true)) {
|
||||||
return PasswordAuthentication(proxyUsername, proxyPassword.toCharArray())
|
return PasswordAuthentication(
|
||||||
|
proxyUsername,
|
||||||
|
proxyPassword.toCharArray(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -357,13 +406,18 @@ fun applicationSetup() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
download { github() }
|
download { github() }
|
||||||
|
settings { windowlessRenderingEnabled = true }
|
||||||
|
appHandler(
|
||||||
|
KCEF.AppHandler(
|
||||||
|
arrayOf("--disable-gpu", "--off-screen-rendering-enabled"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
val kcefDir = Path(applicationDirs.dataRoot) / "bin/kcef"
|
val kcefDir = Path(applicationDirs.dataRoot) / "bin/kcef"
|
||||||
kcefDir.createDirectories()
|
kcefDir.createDirectories()
|
||||||
installDir(kcefDir.toFile())
|
installDir(kcefDir.toFile())
|
||||||
},
|
},
|
||||||
onError = {
|
onError = { it?.printStackTrace() },
|
||||||
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