mirror of
https://github.com/Suwayomi/TachideskJUI.git
synced 2025-12-10 06:42:05 +01:00
Simple domain module
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="ca.gosyer.jui.domain"/>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user