mirror of
https://github.com/Suwayomi/Suwayomi-Server.git
synced 2026-07-03 10:54:38 -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:
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user