Simple domain module

This commit is contained in:
Syer10
2022-06-30 11:11:01 -04:00
parent 873f3715a6
commit c97528e0bf
33 changed files with 240 additions and 110 deletions

View File

@@ -0,0 +1,81 @@
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id(libs.plugins.kotlin.multiplatform.get().pluginId)
id(libs.plugins.kotlin.serialization.get().pluginId)
id(libs.plugins.android.library.get().pluginId)
id(libs.plugins.ksp.get().pluginId)
id(libs.plugins.buildkonfig.get().pluginId)
id(libs.plugins.kotlinter.get().pluginId)
}
kotlin {
android {
compilations {
all {
kotlinOptions.jvmTarget = Config.androidJvmTarget.toString()
}
}
}
jvm("desktop") {
compilations {
all {
kotlinOptions.jvmTarget = Config.desktopJvmTarget.toString()
}
}
}
sourceSets {
all {
languageSettings {
optIn("kotlin.RequiresOptIn")
optIn("kotlinx.coroutines.ExperimentalCoroutinesApi")
}
}
val commonMain by getting {
dependencies {
api(kotlin("stdlib-common"))
api(libs.coroutines.core)
api(libs.serialization.json)
api(libs.kotlinInject.runtime)
api(libs.ktor.core)
api(libs.ktor.websockets)
api(libs.okio)
api(libs.dateTime)
api(projects.core)
api(projects.i18n)
api(projects.data)
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test-common"))
implementation(kotlin("test-annotations-common"))
}
}
val desktopMain by getting {
dependencies {
api(kotlin("stdlib-jdk8"))
}
}
val desktopTest by getting {
}
val androidMain by getting {
dependencies {
api(kotlin("stdlib-jdk8"))
}
}
val androidTest by getting {
}
}
}
dependencies {
add("kspDesktop", libs.kotlinInject.compiler)
add("kspAndroid", libs.kotlinInject.compiler)
}
buildkonfig {
packageName = "ca.gosyer.jui.domain.build"
}

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="ca.gosyer.jui.domain"/>

View File

@@ -0,0 +1,46 @@
/*
* 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/.
*/
package ca.gosyer.jui.domain
import ca.gosyer.jui.core.di.AppScope
import ca.gosyer.jui.data.DataComponent
import ca.gosyer.jui.domain.download.DownloadService
import ca.gosyer.jui.domain.library.LibraryUpdateService
import ca.gosyer.jui.domain.migration.RunMigrations
import ca.gosyer.jui.domain.update.UpdateChecker
import me.tatarka.inject.annotations.Provides
actual interface DomainComponent : DataComponent {
val downloadService: DownloadService
val libraryUpdateService: LibraryUpdateService
val migrations: RunMigrations
val updateChecker: UpdateChecker
@get:AppScope
@get:Provides
val libraryUpdateServiceFactory: LibraryUpdateService
get() = LibraryUpdateService(serverPreferences, http)
@get:AppScope
@get:Provides
val downloadServiceFactory: DownloadService
get() = DownloadService(serverPreferences, http)
@get:AppScope
@get:Provides
val migrationsFactory: RunMigrations
get() = RunMigrations(migrationPreferences, readerPreferences)
@get:AppScope
@get:Provides
val updateCheckerFactory: UpdateChecker
get() = UpdateChecker(updatePreferences, http)
}

View File

@@ -0,0 +1,9 @@
/*
* 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/.
*/
package ca.gosyer.jui.domain
expect interface DomainComponent

View File

@@ -0,0 +1,112 @@
/*
* 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/.
*/
package ca.gosyer.jui.domain.base
import ca.gosyer.jui.core.lang.throwIfCancellation
import ca.gosyer.jui.data.server.Http
import ca.gosyer.jui.data.server.ServerPreferences
import io.ktor.client.plugins.websocket.ws
import io.ktor.http.URLProtocol
import io.ktor.websocket.Frame
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.serialization.json.Json
import org.lighthousegames.logging.logging
@OptIn(DelicateCoroutinesApi::class)
abstract class WebsocketService(
protected val serverPreferences: ServerPreferences,
protected val client: Http
) {
protected val json = Json {
ignoreUnknownKeys = true
}
protected abstract val _status: MutableStateFlow<Status>
protected val serverUrl = serverPreferences.serverUrl().stateIn(GlobalScope)
private var errorConnectionCount = 0
private var job: Job? = null
fun init() {
errorConnectionCount = 0
job?.cancel()
job = serverUrl
.mapLatest { serverUrl ->
_status.value = Status.STARTING
while (true) {
if (errorConnectionCount > 3) {
_status.value = Status.STOPPED
throw CancellationException("Finish")
}
runCatching {
client.ws(
host = serverUrl.host,
port = serverUrl.port,
path = serverUrl.encodedPath + query,
request = {
if (serverUrl.port == 443) {
url.protocol = URLProtocol.WSS
url.port = serverUrl.port
}
}
) {
errorConnectionCount = 0
_status.value = Status.RUNNING
send(Frame.Text("STATUS"))
incoming.receiveAsFlow()
.filterIsInstance<Frame.Text>()
.mapLatest(::onReceived)
.catch {
log.warn(it) { "Error running websocket" }
}
.collect()
}
}.throwIfCancellation().isFailure.let {
_status.value = Status.STARTING
if (it) errorConnectionCount++
}
}
}
.catch {
_status.value = Status.STOPPED
log.warn(it) { "Error while running websocket service" }
}
.launchIn(GlobalScope)
}
abstract val query: String
abstract suspend fun onReceived(frame: Frame.Text)
enum class Status {
STARTING,
RUNNING,
STOPPED
}
enum class Actions {
STOP,
START,
RESTART
}
private companion object {
val log = logging()
}
}

View File

@@ -0,0 +1,55 @@
/*
* 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/.
*/
package ca.gosyer.jui.domain.download
import ca.gosyer.jui.data.download.model.DownloadChapter
import ca.gosyer.jui.data.download.model.DownloadStatus
import ca.gosyer.jui.data.download.model.DownloaderStatus
import ca.gosyer.jui.data.server.Http
import ca.gosyer.jui.data.server.ServerPreferences
import ca.gosyer.jui.data.server.requests.downloadsQuery
import ca.gosyer.jui.domain.base.WebsocketService
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.decodeFromString
import me.tatarka.inject.annotations.Inject
class DownloadService @Inject constructor(
serverPreferences: ServerPreferences,
client: Http
) : WebsocketService(serverPreferences, client) {
override val _status: MutableStateFlow<Status>
get() = status
override val query: String
get() = downloadsQuery()
override suspend fun onReceived(frame: Frame.Text) {
val status = json.decodeFromString<DownloadStatus>(frame.readText())
downloaderStatus.value = status.status
downloadQueue.value = status.queue
}
companion object {
val status = MutableStateFlow(Status.STARTING)
val downloadQueue = MutableStateFlow(emptyList<DownloadChapter>())
val downloaderStatus = MutableStateFlow(DownloaderStatus.Stopped)
fun registerWatch(mangaId: Long) =
downloadQueue
.map {
it.filter { it.mangaId == mangaId }
}
fun registerWatches(mangaIds: Set<Long>) =
downloadQueue
.map {
it.filter { it.mangaId in mangaIds }
}
}
}

View File

@@ -0,0 +1,39 @@
/*
* 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/.
*/
package ca.gosyer.jui.domain.library
import ca.gosyer.jui.data.library.model.UpdateStatus
import ca.gosyer.jui.data.server.Http
import ca.gosyer.jui.data.server.ServerPreferences
import ca.gosyer.jui.data.server.requests.updatesQuery
import ca.gosyer.jui.domain.base.WebsocketService
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.decodeFromString
import me.tatarka.inject.annotations.Inject
import org.lighthousegames.logging.logging
class LibraryUpdateService @Inject constructor(
serverPreferences: ServerPreferences,
client: Http
) : WebsocketService(serverPreferences, client) {
override val _status: MutableStateFlow<Status> = MutableStateFlow(Status.STARTING)
override val query: String
get() = updatesQuery()
override suspend fun onReceived(frame: Frame.Text) {
val status = json.decodeFromString<UpdateStatus>(frame.readText())
log.info { status }
}
private companion object {
private val log = logging()
}
}

View File

@@ -0,0 +1,29 @@
/*
* 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/.
*/
package ca.gosyer.jui.domain.migration
import ca.gosyer.jui.data.migration.MigrationPreferences
import ca.gosyer.jui.data.reader.ReaderPreferences
import ca.gosyer.jui.domain.build.BuildKonfig
import me.tatarka.inject.annotations.Inject
class RunMigrations @Inject constructor(
private val migrationPreferences: MigrationPreferences,
private val readerPreferences: ReaderPreferences
) {
fun runMigrations() {
val code = migrationPreferences.version().get()
if (code <= 0) {
readerPreferences.modes().get().forEach {
readerPreferences.getMode(it).direction().delete()
}
migrationPreferences.version().set(BuildKonfig.MIGRATION_CODE)
return
}
}
}

View File

@@ -0,0 +1,93 @@
/*
* 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/.
*/
package ca.gosyer.jui.domain.reader
import ca.gosyer.jui.core.prefs.getAsFlow
import ca.gosyer.jui.data.reader.ReaderModePreferences
import ca.gosyer.jui.data.reader.ReaderPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
class ReaderModeWatch(
private val readerPreferences: ReaderPreferences,
private val scope: CoroutineScope,
private val mode: StateFlow<String> = readerPreferences.mode().stateIn(scope),
initialPreferences: ReaderModePreferences = readerPreferences.getMode(
mode.value
)
) {
private val preferenceJobs = mutableListOf<Job>()
val direction = MutableStateFlow(initialPreferences.direction().get())
val continuous = MutableStateFlow(initialPreferences.continuous().get())
val padding = MutableStateFlow(initialPreferences.padding().get())
val imageScale = MutableStateFlow(initialPreferences.imageScale().get())
val fitSize = MutableStateFlow(initialPreferences.fitSize().get())
val maxSize = MutableStateFlow(initialPreferences.maxSize().get())
val navigationMode = MutableStateFlow(initialPreferences.navigationMode().get())
init {
setupJobs(mode.value)
mode
.mapLatest { mode ->
setupJobs(mode)
}
.launchIn(scope)
}
private fun setupJobs(mode: String) {
preferenceJobs.forEach {
it.cancel()
}
preferenceJobs.clear()
val preferences = readerPreferences.getMode(mode)
preferenceJobs += preferences.direction()
.getAsFlow {
direction.value = it
}
.launchIn(scope)
preferenceJobs += preferences.continuous()
.getAsFlow {
continuous.value = it
}
.launchIn(scope)
preferenceJobs += preferences.padding()
.getAsFlow {
padding.value = it
}
.launchIn(scope)
preferenceJobs += preferences.imageScale()
.getAsFlow {
imageScale.value = it
}
.launchIn(scope)
preferenceJobs += preferences.fitSize()
.getAsFlow {
fitSize.value = it
}
.launchIn(scope)
preferenceJobs += preferences.maxSize()
.getAsFlow {
maxSize.value = it
}
.launchIn(scope)
preferenceJobs += preferences.navigationMode()
.getAsFlow {
navigationMode.value = it
}
.launchIn(scope)
}
}

View File

@@ -0,0 +1,76 @@
/*
* 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/.
*/
package ca.gosyer.jui.domain.update
import ca.gosyer.jui.core.lang.IO
import ca.gosyer.jui.data.server.Http
import ca.gosyer.jui.data.update.UpdatePreferences
import ca.gosyer.jui.data.update.model.GithubRelease
import ca.gosyer.jui.domain.build.BuildKonfig
import io.ktor.client.call.body
import io.ktor.client.request.get
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import me.tatarka.inject.annotations.Inject
class UpdateChecker @Inject constructor(
private val updatePreferences: UpdatePreferences,
private val client: Http
) {
fun checkForUpdates(manualFetch: Boolean) = flow {
if (!manualFetch && !updatePreferences.enabled().get()) return@flow
val latestRelease = client.get(
"https://api.github.com/repos/$GITHUB_REPO/releases/latest"
).body<GithubRelease>()
if (isNewVersion(latestRelease.version)) {
emit(Update.UpdateFound(latestRelease))
} else {
emit(Update.NoUpdatesFound)
}
}.flowOn(Dispatchers.IO)
sealed class Update {
data class UpdateFound(val release: GithubRelease) : Update()
object NoUpdatesFound : Update()
}
// Thanks to Tachiyomi for inspiration
private fun isNewVersion(versionTag: String): Boolean {
// Removes prefixes like "r" or "v"
val newVersion = versionTag.replace("[^\\d.]".toRegex(), "")
return if (BuildKonfig.IS_PREVIEW) {
// Preview builds: based on releases in "Suwayomi/Tachidesk-JUI-preview" repo
// tagged as something like "r123"
newVersion.toInt() > BuildKonfig.PREVIEW_BUILD
} else {
// Release builds: based on releases in "Suwayomi/Tachidesk-JUI" repo
// tagged as something like "v1.1.2"
newVersion != BuildKonfig.VERSION
}
}
companion object {
private val GITHUB_REPO = if (BuildKonfig.IS_PREVIEW) {
"Suwayomi/Tachidesk-JUI-preview"
} else {
"Suwayomi/Tachidesk-JUI"
}
private val RELEASE_TAG: String by lazy {
if (BuildKonfig.IS_PREVIEW) {
"r${BuildKonfig.PREVIEW_BUILD}"
} else {
"v${BuildKonfig.VERSION}"
}
}
val RELEASE_URL = "https://github.com/$GITHUB_REPO/releases/tag/$RELEASE_TAG"
}
}

View File

@@ -0,0 +1,55 @@
/*
* 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/.
*/
package ca.gosyer.jui.domain
import ca.gosyer.jui.core.di.AppScope
import ca.gosyer.jui.data.DataComponent
import ca.gosyer.jui.domain.download.DownloadService
import ca.gosyer.jui.domain.library.LibraryUpdateService
import ca.gosyer.jui.domain.migration.RunMigrations
import ca.gosyer.jui.domain.server.ServerService
import ca.gosyer.jui.domain.update.UpdateChecker
import me.tatarka.inject.annotations.Provides
actual interface DomainComponent : DataComponent {
val downloadService: DownloadService
val libraryUpdateService: LibraryUpdateService
val migrations: RunMigrations
val updateChecker: UpdateChecker
val serverService: ServerService
@get:AppScope
@get:Provides
val serverServiceFactory: ServerService
get() = ServerService(serverHostPreferences)
@get:AppScope
@get:Provides
val libraryUpdateServiceFactory: LibraryUpdateService
get() = LibraryUpdateService(serverPreferences, http)
@get:AppScope
@get:Provides
val downloadServiceFactory: DownloadService
get() = DownloadService(serverPreferences, http)
@get:AppScope
@get:Provides
val migrationsFactory: RunMigrations
get() = RunMigrations(migrationPreferences, readerPreferences)
@get:AppScope
@get:Provides
val updateCheckerFactory: UpdateChecker
get() = UpdateChecker(updatePreferences, http)
}

View File

@@ -0,0 +1,204 @@
/*
* 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/.
*/
package ca.gosyer.jui.domain.server
import ca.gosyer.jui.core.io.copyTo
import ca.gosyer.jui.core.io.userDataDir
import ca.gosyer.jui.core.lang.withIOContext
import ca.gosyer.jui.data.server.ServerHostPreferences
import ca.gosyer.jui.domain.build.BuildKonfig
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import me.tatarka.inject.annotations.Inject
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath
import okio.buffer
import okio.source
import org.lighthousegames.logging.logging
import java.io.File.pathSeparatorChar
import java.io.IOException
import java.io.Reader
import java.util.jar.Attributes
import java.util.jar.JarInputStream
import kotlin.concurrent.thread
import kotlin.io.path.absolutePathString
import kotlin.io.path.exists
import kotlin.io.path.isExecutable
@OptIn(DelicateCoroutinesApi::class)
class ServerService @Inject constructor(
private val serverHostPreferences: ServerHostPreferences
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val host = serverHostPreferences.host().stateIn(GlobalScope)
private val _initialized = MutableStateFlow(
if (host.value) {
ServerResult.STARTING
} else {
ServerResult.UNUSED
}
)
val initialized = _initialized.asStateFlow()
private var process: Process? = null
fun startAnyway() {
_initialized.value = ServerResult.UNUSED
}
@Throws(IOException::class)
private suspend fun copyJar(jarFile: Path) {
javaClass.getResourceAsStream("/Tachidesk.jar")?.source()
?.copyTo(FileSystem.SYSTEM.sink(jarFile).buffer())
}
private fun getJavaFromPath(javaPath: Path): String? {
val javaExeFile = javaPath.resolve("java.exe").toNioPath()
val javaUnixFile = javaPath.resolve("java").toNioPath()
return when {
javaExeFile.exists() && javaExeFile.isExecutable() -> javaExeFile.absolutePathString()
javaUnixFile.exists() && javaUnixFile.isExecutable() -> javaUnixFile.absolutePathString()
else -> null
}
}
private fun getRuntimeJava(): String? {
return System.getProperty("java.home")?.let { getJavaFromPath(it.toPath().resolve("bin")) }
}
private fun getPossibleJava(): String? {
return System.getProperty("java.library.path")?.split(pathSeparatorChar)
.orEmpty()
.asSequence()
.mapNotNull {
val file = it.toPath()
if (file.toString().contains("java") || file.toString().contains("jdk")) {
if (file.name.equals("bin", true)) {
file
} else file.resolve("bin")
} else null
}
.mapNotNull { getJavaFromPath(it) }
.firstOrNull()
}
private suspend fun runService() {
process?.destroy()
process?.waitFor()
_initialized.value = if (host.value) {
ServerResult.STARTING
} else {
ServerResult.UNUSED
return
}
val handler = CoroutineExceptionHandler { _, throwable ->
log.error(throwable) { "Error launching Tachidesk.jar" }
if (_initialized.value == ServerResult.STARTING || _initialized.value == ServerResult.STARTED) {
_initialized.value = ServerResult.FAILED
}
}
withContext(handler) {
val jarFile = userDataDir / "Tachidesk.jar"
if (!FileSystem.SYSTEM.exists(jarFile)) {
log.info { "Copying server to resources" }
withIOContext { copyJar(jarFile) }
} else {
try {
val jarVersion = withIOContext {
JarInputStream(FileSystem.SYSTEM.source(jarFile).buffer().inputStream()).use { jar ->
jar.manifest?.mainAttributes?.getValue(Attributes.Name.IMPLEMENTATION_VERSION)?.toIntOrNull()
}
}
if (jarVersion != BuildKonfig.SERVER_CODE) {
log.info { "Updating server file from resources" }
withIOContext { copyJar(jarFile) }
}
} catch (e: IOException) {
log.error(e) {
"Error accessing server jar, cannot update server, ${BuildKonfig.NAME} may not work properly"
}
}
}
val javaPath = getRuntimeJava() ?: getPossibleJava() ?: "java"
log.info { "Starting server with $javaPath" }
val properties = serverHostPreferences.properties()
log.info { "Using server properties:\n" + properties.joinToString(separator = "\n") }
withIOContext {
val reader: Reader
process = ProcessBuilder(javaPath, *properties, "-jar", jarFile.toString())
.redirectErrorStream(true)
.start()
.also {
reader = it.inputStream.reader()
}
log.info { "Server started successfully" }
val log = logging("Server")
reader.forEachLine {
if (_initialized.value == ServerResult.STARTING) {
when {
it.contains("Javalin started") ->
_initialized.value = ServerResult.STARTED
it.contains("Javalin has stopped") ->
_initialized.value = ServerResult.FAILED
}
}
log.info { it }
}
if (_initialized.value == ServerResult.STARTING) {
_initialized.value = ServerResult.FAILED
}
log.info { "Server closed" }
val exitVal = process?.waitFor()
log.info { "Process exitValue: $exitVal" }
process = null
}
}
}
fun startServer() {
scope.coroutineContext.cancelChildren()
host
.mapLatest {
runService()
}
.launchIn(scope)
}
init {
Runtime.getRuntime().addShutdownHook(
thread(start = false) {
process?.destroy()
process = null
}
)
}
enum class ServerResult {
UNUSED,
STARTING,
STARTED,
FAILED;
}
private companion object {
private val log = logging()
}
}