Localize WebView and Login pages (#1522)

* Localize WebView and Login pages

* Switch to JTE for page rendering

* Lint

* Add gradle task dependency

* JTE -> KTE

* ShouldRunAfter

* I guess we must

---------

Co-authored-by: Syer10 <syer10@users.noreply.github.com>
This commit is contained in:
Constantin Piber
2025-07-15 21:38:20 +02:00
committed by GitHub
parent 3bac176bf6
commit d050bfdc68
8 changed files with 115 additions and 42 deletions

View File

@@ -11,6 +11,7 @@ plugins {
alias(libs.plugins.download) alias(libs.plugins.download)
alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.moko) apply false alias(libs.plugins.moko) apply false
alias(libs.plugins.jte) apply false
} }
allprojects { allprojects {

View File

@@ -4,6 +4,7 @@ coroutines = "1.10.2"
serialization = "1.9.0" serialization = "1.9.0"
okhttp = "5.1.0" # Major version is locked by Tachiyomi extensions okhttp = "5.1.0" # Major version is locked by Tachiyomi extensions
javalin = "6.7.0" javalin = "6.7.0"
jte = "3.2.1"
jackson = "2.18.3" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency` jackson = "2.18.3" # jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
exposed = "0.61.0" exposed = "0.61.0"
dex2jar = "v64" # Stuck until https://github.com/ThexXTURBOXx/dex2jar/issues/27 is fixed dex2jar = "v64" # Stuck until https://github.com/ThexXTURBOXx/dex2jar/issues/27 is fixed
@@ -49,9 +50,12 @@ okio = "com.squareup.okio:okio:3.15.0"
# Javalin api # Javalin api
javalin-core = { module = "io.javalin:javalin", version.ref = "javalin" } javalin-core = { module = "io.javalin:javalin", version.ref = "javalin" }
javalin-openapi = { module = "io.javalin:javalin-openapi", version.ref = "javalin" } javalin-openapi = { module = "io.javalin:javalin-openapi", version.ref = "javalin" }
javalin-rendering = { module = "io.javalin:javalin-rendering", version.ref = "javalin" }
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } jackson-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" }
jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jackson" } jackson-annotations = { module = "com.fasterxml.jackson.core:jackson-annotations", version.ref = "jackson" }
jte = { module = "gg.jte:jte", version.ref = "jte" }
kte = { module = "gg.jte:jte-kotlin", version.ref = "jte" }
# GraphQL # GraphQL
graphql-kotlin-server = { module = "com.expediagroup:graphql-kotlin-server", version.ref = "graphqlkotlin" } graphql-kotlin-server = { module = "com.expediagroup:graphql-kotlin-server", version.ref = "graphqlkotlin" }
@@ -177,6 +181,9 @@ shadowjar = { id = "com.github.johnrengelman.shadow", version = "8.1.1"}
# Moko # Moko
moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" } moko = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko" }
# JTE
jte = { id = "gg.jte.gradle", version.ref = "jte" }
[bundles] [bundles]
shared = [ shared = [
"kotlin-stdlib-jdk8", "kotlin-stdlib-jdk8",
@@ -216,6 +223,8 @@ okhttp = [
javalin = [ javalin = [
"javalin-core", "javalin-core",
#"javalin-openapi", #"javalin-openapi",
"javalin-rendering",
"jte",
] ]
jackson = [ jackson = [
"jackson-databind", "jackson-databind",

View File

@@ -25,6 +25,11 @@ plugins {
.get() .get()
.pluginId, .pluginId,
) )
id(
libs.plugins.jte
.get()
.pluginId,
)
} }
dependencies { dependencies {
@@ -96,6 +101,12 @@ dependencies {
implementation(libs.cron4j) implementation(libs.cron4j)
implementation(libs.cronUtils) implementation(libs.cronUtils)
compileOnly(libs.kte)
}
jte {
generate()
} }
application { application {
@@ -212,4 +223,8 @@ tasks {
) )
} }
} }
runKtlintCheckOverMainSourceSet {
mustRunAfter(generateJte)
}
} }

View File

@@ -80,4 +80,23 @@
<string name="manga_status_publishing_finished">Publishing Finished</string> <string name="manga_status_publishing_finished">Publishing Finished</string>
<string name="manga_status_cancelled">Cancelled</string> <string name="manga_status_cancelled">Cancelled</string>
<string name="manga_status_on_hiatus">On Hiatus</string> <string name="manga_status_on_hiatus">On Hiatus</string>
</resources>
<string name="label_error">Error</string>
<string name="label_version">Version <xliff:g id="version" example="v2.0.1833">%1$s</xliff:g></string>
<string name="webview_label_title">Suwayomi WebView</string>
<string name="webview_label_disconnected">Disconnected, please refresh</string>
<string name="webview_label_reversescroll">Reverse Scrolling</string>
<string name="webview_label_bindingshint">Note: While focus is on the WebView part, no keybinds, including refresh, will be handled by the browser.</string>
<string name="webview_label_init">Initializing... Please wait</string>
<string name="webview_label_getstarted">Enter a URL to get started</string>
<string name="webview_label_loading">Loading page...</string>
<string name="webview_placeholder_url">Enter URL...</string>
<string name="login_label_title">Suwayomi Login</string>
<string name="login_label_username">Username</string>
<string name="login_label_password">Password</string>
<string name="login_label_login">Log In</string>
<string name="login_placeholder_username">Type username...</string>
<string name="login_placeholder_password">Secret...</string>
</resources>

View File

@@ -1,8 +1,14 @@
@import suwayomi.tachidesk.i18n.MR
@import suwayomi.tachidesk.server.generated.BuildConfig
@param locale: java.util.Locale
@param error: String
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Suwayomi Login</title> <title>${MR.strings.login_label_title.localized(locale)}</title>
<style> <style>
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -143,22 +149,22 @@
<h1>Suwayomi</h1> <h1>Suwayomi</h1>
</header> </header>
<main> <main>
<div class="error">[ERROR]</div> <div class="error">${error}</div>
<form method="POST"> <form method="POST">
<h2>Login</h2> <h2>Login</h2>
<div class="controls"> <div class="controls">
<label for="user">Username:</label> <label for="user">${MR.strings.login_label_username.localized(locale)}:</label>
<input type="text" name="user" id="user" required placeholder="Type username..."/> <input type="text" name="user" id="user" required placeholder="${MR.strings.login_placeholder_username.localized(locale)}"/>
<label for="pass">Password:</label> <label for="pass">${MR.strings.login_label_password.localized(locale)}:</label>
<input type="password" name="pass" id="pass" required placeholder="Secret..."/> <input type="password" name="pass" id="pass" required placeholder="${MR.strings.login_placeholder_password.localized(locale)}"/>
</div> </div>
<div class="submit"> <div class="submit">
<button type="submit">Log In</button> <button type="submit">${MR.strings.login_label_login.localized(locale)}</button>
</div> </div>
</form> </form>
</main> </main>
<footer> <footer>
<p>Suwayomi: Version [VERSION]</p> <p>Suwayomi: ${MR.strings.label_version.localized(locale, BuildConfig.VERSION)}</p>
</footer> </footer>
</body> </body>
</html> </html>

View File

@@ -1,8 +1,12 @@
@import suwayomi.tachidesk.i18n.MR
@param locale: java.util.Locale
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" />
<title>Suwayomi Webview</title> <title>${MR.strings.webview_label_title.localized(locale)}</title>
<style> <style>
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -20,7 +24,7 @@
letter-spacing: 0em; letter-spacing: 0em;
} }
body.disconnected::after { body.disconnected::after {
content: 'Disconnected, please refresh'; content: "${MR.strings.webview_label_disconnected.localized(locale)}";
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgba(150, 0, 0, 0.5); background: rgba(150, 0, 0, 0.5);
@@ -141,18 +145,18 @@
</head> </head>
<body> <body>
<header> <header>
<h1 id="title">Suwayomi: WebView</h1> <h1 id="title">${MR.strings.webview_label_title.localized(locale)}</h1>
<nav> <nav>
<form id="browseForm"> <form id="browseForm">
<input type="text" id="url" name="url" placeholder="Enter URL..." disabled/> <input type="text" id="url" name="url" placeholder="${MR.strings.webview_placeholder_url.localized(locale)}" disabled/>
<button type="submit" id="goButton" disabled><span class="arrow-right"></span></button> <button type="submit" id="goButton" disabled><span class="arrow-right"></span></button>
</form> </form>
<label><input type="checkbox" id="reverseScroll" disabled/> Reverse Scrolling</label> <label><input type="checkbox" id="reverseScroll" disabled/> ${MR.strings.webview_label_reversescroll.localized(locale)}</label>
</nav> </nav>
<p><i>Note: While focus is on the WebView part, no keybinds, including refresh, will be handled by the browser</i></p> <p><i>${MR.strings.webview_label_bindingshint.localized(locale)}</i></p>
</header> </header>
<main> <main>
<div class="message" id="message">Initializing... Please wait</div> <div class="message" id="message">${MR.strings.webview_label_init.localized(locale)}</div>
<div class="status" id="status"></div> <div class="status" id="status"></div>
<canvas id="frame"></canvas> <canvas id="frame"></canvas>
<input type="text" id="inputtrap" autocomplete="off"/> <input type="text" id="inputtrap" autocomplete="off"/>
@@ -168,6 +172,7 @@
const urlInput = document.getElementById('url'); const urlInput = document.getElementById('url');
const titleDiv = document.getElementById('title'); const titleDiv = document.getElementById('title');
const reverseToggle = document.getElementById('reverseScroll'); const reverseToggle = document.getElementById('reverseScroll');
const origTitle = document.title;
try { try {
const socketUrl = (window.location.origin + window.location.pathname).replace(/^http/,'ws'); const socketUrl = (window.location.origin + window.location.pathname).replace(/^http/,'ws');
@@ -200,8 +205,8 @@
const setTitle = (title) => { const setTitle = (title) => {
if (!title) { if (!title) {
document.title = "Suwayomi Webview"; document.title = origTitle;
titleDiv.textContent = "Suwayomi Webview"; titleDiv.textContent = origTitle;
} else { } else {
document.title = "Suwayomi: " + title; document.title = "Suwayomi: " + title;
titleDiv.textContent = "Suwayomi: " + title; titleDiv.textContent = "Suwayomi: " + title;
@@ -213,11 +218,11 @@
urlInput.value = u; urlInput.value = u;
setHash(u); setHash(u);
setTitle(); setTitle();
messageDiv.textContent = 'Enter a URL to get started'; messageDiv.textContent = "${MR.strings.webview_label_getstarted.localized(locale)}";
ctx.clearRect(0, 0, frame.width, frame.height); ctx.clearRect(0, 0, frame.width, frame.height);
return; return;
} }
messageDiv.textContent = "Loading page..."; messageDiv.textContent = "${MR.strings.webview_label_loading.localized(locale)}";
messageDiv.classList.remove('error'); messageDiv.classList.remove('error');
urlInput.value = u; urlInput.value = u;
socket.send(JSON.stringify({ type: 'loadUrl', url: u, width: frame.clientWidth, height: frame.clientHeight })); socket.send(JSON.stringify({ type: 'loadUrl', url: u, width: frame.clientWidth, height: frame.clientHeight }));
@@ -265,7 +270,7 @@
break; break;
case "load": { case "load": {
if (obj.error) { if (obj.error) {
messageDiv.textContent = "Error: " + obj.error; messageDiv.textContent = "${MR.strings.label_error.localized(locale)}: " + obj.error;
messageDiv.classList.add('error'); messageDiv.classList.add('error');
} else { } else {
messageDiv.textContent = ""; messageDiv.textContent = "";
@@ -286,7 +291,7 @@
} break; } break;
case "consoleMessage": { case "consoleMessage": {
const lg = obj.severity == 4 ? console.error : obj.severity == 3 ? console.warn : console.log; const lg = obj.severity == 4 ? console.error : obj.severity == 3 ? console.warn : console.log;
lg(`${obj.source}:${obj.line}:`, obj.message); lg(obj.source + ':' + obj.line + ':', obj.message);
} break; } break;
default: default:
console.warn("Unknown event", obj.type) console.warn("Unknown event", obj.type)
@@ -296,7 +301,7 @@
socket.addEventListener('close', e => { socket.addEventListener('close', e => {
if (e.wasClean) { if (e.wasClean) {
console.log(`WebSocket connection closed cleanly, code=${e.code}, reason=${e.reason}`); console.log(`WebSocket connection closed cleanly, code=` + e.code + `, reason=` + e.reason);
} else { } else {
console.error('WebSocket connection died'); console.error('WebSocket connection died');
} }
@@ -304,7 +309,7 @@
}); });
socket.addEventListener('error', e => { socket.addEventListener('error', e => {
messageDiv.textContent = "Error: " + (e.message || e.reason || e); messageDiv.textContent = "${MR.strings.label_error.localized(locale)}: " + (e.message || e.reason || e);
messageDiv.classList.add('error'); messageDiv.classList.add('error');
console.error('WebSocket error:', e); console.error('WebSocket error:', e);
}); });
@@ -417,7 +422,7 @@
attachEvents(); attachEvents();
frameInput.focus(); frameInput.focus();
} catch (e) { } catch (e) {
messageDiv.textContent = "Error: " + (e.message || e); messageDiv.textContent = "${MR.strings.label_error.localized(locale)}: " + (e.message || e);
messageDiv.classList.add('error'); messageDiv.classList.add('error');
console.error(e); console.error(e);
} }

View File

@@ -11,21 +11,31 @@ import io.javalin.http.ContentType
import io.javalin.http.HttpStatus import io.javalin.http.HttpStatus
import io.javalin.websocket.WsConfig import io.javalin.websocket.WsConfig
import suwayomi.tachidesk.global.impl.WebView import suwayomi.tachidesk.global.impl.WebView
import suwayomi.tachidesk.i18n.LocalizationHelper
import suwayomi.tachidesk.server.util.handler import suwayomi.tachidesk.server.util.handler
import suwayomi.tachidesk.server.util.queryParam
import suwayomi.tachidesk.server.util.withOperation import suwayomi.tachidesk.server.util.withOperation
import java.util.Locale
object WebViewController { object WebViewController {
val webview = val webview =
handler( handler(
queryParam<String?>("lang"),
documentWith = { documentWith = {
withOperation { withOperation {
summary("WebView") summary("WebView")
description("Opens and browses WebView") description("Opens and browses WebView")
} }
}, },
behaviorOf = { ctx -> behaviorOf = { ctx, lang ->
val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang)
ctx.contentType(ContentType.TEXT_HTML) ctx.contentType(ContentType.TEXT_HTML)
ctx.result(javaClass.getResourceAsStream("/webview.html")!!) ctx.render(
"Webview.jte",
mapOf(
"locale" to locale,
),
)
}, },
withResults = { mime<String>(HttpStatus.OK, "text/html") }, withResults = { mime<String>(HttpStatus.OK, "text/html") },
) )

View File

@@ -7,6 +7,8 @@ package suwayomi.tachidesk.server
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import gg.jte.ContentType
import gg.jte.TemplateEngine
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.javalin.Javalin import io.javalin.Javalin
import io.javalin.apibuilder.ApiBuilder.path import io.javalin.apibuilder.ApiBuilder.path
@@ -15,6 +17,7 @@ import io.javalin.http.HttpStatus
import io.javalin.http.RedirectResponse import io.javalin.http.RedirectResponse
import io.javalin.http.UnauthorizedResponse import io.javalin.http.UnauthorizedResponse
import io.javalin.http.staticfiles.Location import io.javalin.http.staticfiles.Location
import io.javalin.rendering.template.JavalinJte
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -25,14 +28,15 @@ import org.eclipse.jetty.server.ServerConnector
import suwayomi.tachidesk.global.GlobalAPI import suwayomi.tachidesk.global.GlobalAPI
import suwayomi.tachidesk.graphql.GraphQL import suwayomi.tachidesk.graphql.GraphQL
import suwayomi.tachidesk.graphql.types.AuthMode import suwayomi.tachidesk.graphql.types.AuthMode
import suwayomi.tachidesk.i18n.LocalizationHelper
import suwayomi.tachidesk.manga.MangaAPI import suwayomi.tachidesk.manga.MangaAPI
import suwayomi.tachidesk.opds.OpdsAPI import suwayomi.tachidesk.opds.OpdsAPI
import suwayomi.tachidesk.server.generated.BuildConfig
import suwayomi.tachidesk.server.util.Browser import suwayomi.tachidesk.server.util.Browser
import suwayomi.tachidesk.server.util.WebInterfaceManager import suwayomi.tachidesk.server.util.WebInterfaceManager
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.IOException import java.io.IOException
import java.net.URLEncoder import java.net.URLEncoder
import java.util.Locale
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import kotlin.concurrent.thread import kotlin.concurrent.thread
import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.days
@@ -49,6 +53,8 @@ object JavalinSetup {
fun javalinSetup() { fun javalinSetup() {
val app = val app =
Javalin.create { config -> Javalin.create { config ->
val templateEngine = TemplateEngine.createPrecompiled(ContentType.Html)
config.fileRenderer(JavalinJte(templateEngine))
if (serverConfig.webUIEnabled.value) { if (serverConfig.webUIEnabled.value) {
val serveWebUI = { val serveWebUI = {
config.spaRoot.addFile("/", applicationDirs.webUIRoot + "/index.html", Location.EXTERNAL) config.spaRoot.addFile("/", applicationDirs.webUIRoot + "/index.html", Location.EXTERNAL)
@@ -118,16 +124,17 @@ object JavalinSetup {
} }
app.get("/login.html") { ctx -> app.get("/login.html") { ctx ->
var page = val locale: Locale = LocalizationHelper.ctxToLocale(ctx)
this::class.java
.getResourceAsStream("/static/login.html")!!
.use { it.readAllBytes() }
.toString(Charsets.UTF_8)
page = page.replace("[VERSION]", BuildConfig.VERSION).replace("[ERROR]", "")
ctx.header("content-type", "text/html") ctx.header("content-type", "text/html")
val httpCacheSeconds = 1.days.inWholeSeconds val httpCacheSeconds = 1.days.inWholeSeconds
ctx.header("cache-control", "max-age=$httpCacheSeconds") ctx.header("cache-control", "max-age=$httpCacheSeconds")
ctx.result(page) ctx.render(
"Login.jte",
mapOf(
"locale" to locale,
"error" to "",
),
)
} }
app.post("/login.html") { ctx -> app.post("/login.html") { ctx ->
@@ -147,15 +154,16 @@ object JavalinSetup {
throw RedirectResponse(HttpStatus.SEE_OTHER) throw RedirectResponse(HttpStatus.SEE_OTHER)
} }
var page = val locale: Locale = LocalizationHelper.ctxToLocale(ctx)
this::class.java
.getResourceAsStream("/static/login.html")!!
.use { it.readAllBytes() }
.toString(Charsets.UTF_8)
page = page.replace("[VERSION]", BuildConfig.VERSION).replace("[ERROR]", "Invalid username or password")
ctx.header("content-type", "text/html") ctx.header("content-type", "text/html")
ctx.req().session.invalidate() ctx.req().session.invalidate()
ctx.result(page) ctx.render(
"Login.jte",
mapOf(
"locale" to locale,
"error" to "Invalid username or password",
),
)
} }
app.beforeMatched { ctx -> app.beforeMatched { ctx ->