diff --git a/app/desktop/src/jvmMain/kotlin/dev/datlag/burningseries/InitCEF.kt b/app/desktop/src/jvmMain/kotlin/dev/datlag/burningseries/InitCEF.kt new file mode 100644 index 00000000..4ec58164 --- /dev/null +++ b/app/desktop/src/jvmMain/kotlin/dev/datlag/burningseries/InitCEF.kt @@ -0,0 +1,61 @@ +package dev.datlag.burningseries + +import androidx.compose.runtime.* +import dev.datlag.burningseries.common.LocalRestartRequired +import dev.datlag.burningseries.common.withIOContext +import dev.datlag.burningseries.other.CEFState +import dev.datlag.burningseries.other.LocalCEFInitialization +import dev.datlag.burningseries.window.ApplicationDisposer +import dev.datlag.kcef.KCEF +import dev.datlag.kcef.KCEFBuilder +import java.io.File + +@Composable +fun InitCEF(content: @Composable () -> Unit) { + val restartRequiredInitial = LocalRestartRequired.current + var restartRequired by remember { mutableStateOf(restartRequiredInitial) } + val cefState = remember { mutableStateOf(CEFState.LOCATING) } + + LaunchedEffect(ApplicationDisposer.current) { + withIOContext { + KCEF.init( + builder = { + installDir(File(AppIO.getWriteableExecutableFolder(), "kcef-bundle")) + progress { + onLocating { + cefState.value = CEFState.LOCATING + } + onDownloading { + cefState.value = CEFState.Downloading(it) + } + onExtracting { + cefState.value = CEFState.EXTRACTING + } + onInstall { + cefState.value = CEFState.INSTALLING + } + onInitializing { + cefState.value = CEFState.INITIALIZING + } + onInitialized { + cefState.value = CEFState.INITIALIZED + } + } + settings { + logSeverity = KCEFBuilder.Settings.LogSeverity.Disable + } + }, + onRestartRequired = { + restartRequired = true + } + ) + } + } + + CompositionLocalProvider( + LocalRestartRequired provides restartRequired, + LocalCEFInitialization provides cefState + ) { + content() + } +} \ No newline at end of file diff --git a/app/desktop/src/jvmMain/kotlin/dev/datlag/burningseries/Main.kt b/app/desktop/src/jvmMain/kotlin/dev/datlag/burningseries/Main.kt index 26be9acc..f72975d0 100644 --- a/app/desktop/src/jvmMain/kotlin/dev/datlag/burningseries/Main.kt +++ b/app/desktop/src/jvmMain/kotlin/dev/datlag/burningseries/Main.kt @@ -74,25 +74,27 @@ private fun runWindow() { ) { LifecycleController(lifecycle, windowState) - CompositionLocalProvider( - LocalLifecycleOwner provides lifecycleOwner, - LocalWindow provides this.window, - LocalKamelConfig provides imageConfig - ) { - App(di) { - PredictiveBackGestureOverlay( - backDispatcher = backDispatcher, - backIcon = { progress, _ -> - PredictiveBackGestureIcon( - imageVector = Icons.Default.ArrowBackIosNew, - progress = progress, - iconTintColor = MaterialTheme.colorScheme.onSecondaryContainer, - backgroundColor = MaterialTheme.colorScheme.secondaryContainer - ) - }, - modifier = Modifier.fillMaxSize() - ) { - root.render() + InitCEF { + CompositionLocalProvider( + LocalLifecycleOwner provides lifecycleOwner, + LocalWindow provides this.window, + LocalKamelConfig provides imageConfig + ) { + App(di) { + PredictiveBackGestureOverlay( + backDispatcher = backDispatcher, + backIcon = { progress, _ -> + PredictiveBackGestureIcon( + imageVector = Icons.Default.ArrowBackIosNew, + progress = progress, + iconTintColor = MaterialTheme.colorScheme.onSecondaryContainer, + backgroundColor = MaterialTheme.colorScheme.secondaryContainer + ) + }, + modifier = Modifier.fillMaxSize() + ) { + root.render() + } } } } diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 9321da75..6efd3f30 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -63,6 +63,7 @@ kotlin { api(libs.windowsize.multiplatform) api(libs.insetsx) + api(libs.webview) api(libs.ktor) api(libs.ktor.content.negotiation) @@ -98,6 +99,7 @@ kotlin { api(libs.context.menu) api(libs.window.styler) api(libs.ktor.jvm) + api(libs.appdirs) } } diff --git a/app/shared/src/desktopMain/kotlin/dev/datlag/burningseries/AppIO.kt b/app/shared/src/desktopMain/kotlin/dev/datlag/burningseries/AppIO.kt index dd063bbb..419b0a22 100644 --- a/app/shared/src/desktopMain/kotlin/dev/datlag/burningseries/AppIO.kt +++ b/app/shared/src/desktopMain/kotlin/dev/datlag/burningseries/AppIO.kt @@ -1,10 +1,17 @@ package dev.datlag.burningseries -import dev.datlag.burningseries.model.common.scopeCatching +import dev.datlag.burningseries.model.common.* +import net.harawata.appdirs.AppDirsFactory +import org.apache.commons.lang3.SystemUtils import java.awt.Toolkit +import java.io.File object AppIO { + private val dirs by lazy { + AppDirsFactory.getInstance() + } + fun applyTitle(title: String) = scopeCatching { val toolkit = Toolkit.getDefaultToolkit() val awtAppClassNameField = toolkit.javaClass.getDeclaredField("awtAppClassName") @@ -17,4 +24,44 @@ object AppIO { awtAppClassNameField.set(toolkit, title) working }.getOrNull() ?: false + + fun getFileInSiteDataDir(name: String): File { + val parentFile = File(dirs.getSiteDataDir(APP_NAME, null, null)) + var returnFile = File(parentFile, name) + if (returnFile.existsRWSafely() + || (returnFile.parentFile ?: parentFile).existsRWSafely() + || (returnFile.parentFile ?: parentFile).mkdirsSafely()) { + return returnFile + } else if (SystemUtils.IS_OS_LINUX) { + val dataDir = File("/usr/local/share/$APP_NAME") + returnFile = File(dataDir, name) + if (returnFile.existsRWSafely() + || (returnFile.parentFile ?: dataDir).existsRWSafely() + || (returnFile.parentFile ?: dataDir).mkdirsSafely()) { + return returnFile + } + + val alternativeDataDir = File(homeDirectory(), ".local/share/flatpak/exports/share/$APP_NAME").apply { + mkdirsSafely() + } + returnFile = File(alternativeDataDir, name) + return returnFile + } + return returnFile + } + + fun getWriteableExecutableFolder(): File { + val resDir = systemProperty("compose.application.resources.dir")?.let { File(it) } + return if (resDir.existsRWSafely()) { + resDir!! + } else { + if (File("./").canWriteSafely()) { + File("./") + } else { + getFileInSiteDataDir("./") + } + } + } + + private const val APP_NAME = "Burning-Series" } \ No newline at end of file diff --git a/app/shared/src/desktopMain/kotlin/dev/datlag/burningseries/common/PlatformExtendCompose.desktop.kt b/app/shared/src/desktopMain/kotlin/dev/datlag/burningseries/common/PlatformExtendCompose.desktop.kt index d6c5cf06..0be9e324 100644 --- a/app/shared/src/desktopMain/kotlin/dev/datlag/burningseries/common/PlatformExtendCompose.desktop.kt +++ b/app/shared/src/desktopMain/kotlin/dev/datlag/burningseries/common/PlatformExtendCompose.desktop.kt @@ -3,6 +3,7 @@ package dev.datlag.burningseries.common import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.ui.Modifier import androidx.compose.foundation.onClick +import androidx.compose.runtime.compositionLocalOf import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.StackAnimation import com.arkivanov.decompose.extensions.compose.jetbrains.stack.animation.fade @@ -33,4 +34,6 @@ actual fun backAnimation( backHandler = backHandler, animation = stackAnimation(fade()), onBack = onBack -) \ No newline at end of file +) + +val LocalRestartRequired = compositionLocalOf { false } \ No newline at end of file diff --git a/app/shared/src/desktopMain/kotlin/dev/datlag/burningseries/other/CEFState.kt b/app/shared/src/desktopMain/kotlin/dev/datlag/burningseries/other/CEFState.kt new file mode 100644 index 00000000..628bb93e --- /dev/null +++ b/app/shared/src/desktopMain/kotlin/dev/datlag/burningseries/other/CEFState.kt @@ -0,0 +1,15 @@ +package dev.datlag.burningseries.other + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalCEFInitialization = staticCompositionLocalOf> { error("No CEFInitialization state provided") } + +sealed interface CEFState { + data object LOCATING : CEFState + data class Downloading(val progress: Float) : CEFState + data object EXTRACTING : CEFState + data object INSTALLING : CEFState + data object INITIALIZING : CEFState + data object INITIALIZED : CEFState +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 8a63e50e..268ff29b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,6 +24,7 @@ buildscript { gradlePluginPortal() maven { url = uri("https://jitpack.io") } maven { url = uri("https://plugins.gradle.org/m2/") } + maven("https://jogamp.org/deployment/maven") } } @@ -34,6 +35,7 @@ allprojects { gradlePluginPortal() maven { url = uri("https://jitpack.io") } maven { url = uri("https://plugins.gradle.org/m2/") } + maven("https://jogamp.org/deployment/maven") } tasks.withType { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1503a6c3..b3486f4b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ activity = "1.8.0" android = "8.1.3" android-core = "1.12.0" appcompat = "1.6.1" +appdirs = "1.2.2" compose = "1.5.10" complete-kotlin = "1.1.0" context-menu = "0.2.0" @@ -22,6 +23,7 @@ ksp = "1.9.20-1.0.14" ktor = "2.3.6" ktorfit = "1.10.0" ktsoup = "0.3.0" +lang = "3.13.0" material = "1.10.0" moko-resources = "0.23.0" multidex = "2.0.1" @@ -34,6 +36,7 @@ serialization-json = "1.6.0" splashscreen = "1.0.1" turbine = "1.0.0" versions = "0.49.0" +webview = "1.7.0" windowsize-multiplatform = "0.3.1" window-styler = "0.3.2" @@ -45,6 +48,7 @@ activity = { group = "androidx.activity", name = "activity-ktx", version.ref = " activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" } android = { group = "androidx.core", name = "core-ktx", version.ref = "android-core" } appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +appdirs = { group = "net.harawata", name = "appdirs", version.ref = "appdirs" } compose-ui-util = { group = "org.jetbrains.compose.ui", name = "ui-util", version.ref = "compose" } context-menu = { group = "io.github.dzirbel", name = "compose-material-context-menu", version.ref = "context-menu" } coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } @@ -72,6 +76,7 @@ ktorfit-ksp = { group = "de.jensklingenberg.ktorfit", name = "ktorfit-ksp", vers ktsoup = { group = "org.drewcarlson", name = "ktsoup-core", version.ref = "ktsoup" } ktsoup-fs = { group = "org.drewcarlson", name = "ktsoup-fs", version.ref = "ktsoup" } ktsoup-ktor = { group = "org.drewcarlson", name = "ktsoup-ktor", version.ref = "ktsoup" } +lang = { group = "org.apache.commons", name = "commons-lang3", version.ref = "lang" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } moko-resources-compose = { group = "dev.icerock.moko", name = "resources-compose", version.ref = "moko-resources" } moko-resources-generator = { group = "dev.icerock.moko", name = "resources-generator", version.ref = "moko-resources" } @@ -85,6 +90,7 @@ splashscreen = { group = "androidx.core", name = "core-splashscreen", version.re stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" } +webview = { group = "io.github.kevinnzou", name = "compose-webview-multiplatform", version.ref = "webview" } windowsize-multiplatform = { group = "dev.chrisbanes.material3", name = "material3-window-size-class-multiplatform", version.ref = "windowsize-multiplatform" } window-styler = { group = "com.mayakapps.compose", name = "window-styler", version.ref = "window-styler" } diff --git a/model/build.gradle.kts b/model/build.gradle.kts index 93683664..631057f2 100644 --- a/model/build.gradle.kts +++ b/model/build.gradle.kts @@ -2,7 +2,6 @@ plugins { alias(libs.plugins.multiplatform) alias(libs.plugins.serialization) alias(libs.plugins.android.library) - id ("kotlin-parcelize") apply false } val artifact = VersionCatalog.artifactName("model") @@ -26,9 +25,15 @@ kotlin { } } - val androidMain by getting { - dependsOn(jvmMain.get()) - apply(plugin = "kotlin-parcelize") + val javaMain by creating { + dependsOn(commonMain) + + jvmMain.get().dependsOn(this) + androidMain.get().dependsOn(this) + } + + jvmMain.get().dependencies { + api(libs.lang) } } } diff --git a/model/src/javaMain/kotlin/dev/datlag/burningseries/model/common/ExtendFile.kt b/model/src/javaMain/kotlin/dev/datlag/burningseries/model/common/ExtendFile.kt new file mode 100644 index 00000000..ea68d3d3 --- /dev/null +++ b/model/src/javaMain/kotlin/dev/datlag/burningseries/model/common/ExtendFile.kt @@ -0,0 +1,215 @@ +package dev.datlag.burningseries.model.common + +import java.io.File +import java.io.RandomAccessFile +import java.nio.channels.FileChannel +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.LinkOption +import java.util.stream.Collectors + +fun File.openReadChannel(): FileChannel { + val reader = RandomAccessFile(this, "r") + return reader.channel +} + +fun File.openWriteChannel(): FileChannel { + val writer = RandomAccessFile(this, "rw") + return writer.channel +} + +fun File?.existsSafely(): Boolean { + if (this == null) { + return false + } + + return scopeCatching { + Files.exists(this.toPath()) + }.getOrNull() ?: scopeCatching { + this.exists() + }.getOrNull() ?: false +} + +fun File.canReadSafely(): Boolean { + return scopeCatching { + Files.isReadable(this.toPath()) + }.getOrNull() ?: scopeCatching { + this.canRead() + }.getOrNull() ?: false +} + +fun File.canWriteSafely(): Boolean { + return scopeCatching { + Files.isWritable(this.toPath()) + }.getOrNull() ?: scopeCatching { + this.canWrite() + }.getOrNull() ?: false +} + +fun File?.existsRSafely(): Boolean { + if (this == null) { + return false + } + + return existsSafely() && canReadSafely() +} + +fun File?.existsRWSafely(): Boolean { + if (this == null) { + return false + } + + return existsSafely() && canReadSafely() && canWriteSafely() +} + +fun File.isSymlinkSafely(): Boolean { + return scopeCatching { + Files.isSymbolicLink(this.toPath()) + }.getOrNull() ?: scopeCatching { + !Files.isRegularFile(this.toPath(), LinkOption.NOFOLLOW_LINKS) + }.getOrNull() ?: false +} + +fun File.getRealFile(): File { + return if (isSymlinkSafely()) scopeCatching { + Files.readSymbolicLink(this.toPath()).toFile() + }.getOrNull() ?: this else this +} + +fun File.isSame(file: File?): Boolean { + var sourceFile = this.getRealFile() + if (!sourceFile.existsSafely()) { + sourceFile = this + } + + var targetFile = file?.getRealFile() ?: file + if (!targetFile.existsSafely()) { + targetFile = file + } + + return if (targetFile == null) { + false + } else { + this == targetFile || scopeCatching { + sourceFile.absoluteFile == targetFile.absoluteFile || Files.isSameFile(sourceFile.toPath(), targetFile.toPath()) + }.getOrNull() ?: false + } +} + +fun Collection.normalize(): List { + val list: MutableList = mutableListOf() + this.forEach { file -> + var realFile = file.getRealFile() + if (!realFile.existsSafely()) { + if (file.existsSafely()) { + realFile = file + } else { + return@forEach + } + } + if (list.firstOrNull { it.isSame(realFile) } == null) { + list.add(realFile) + } + } + return list +} + +fun File.listFilesSafely(): List { + return scopeCatching { + this.listFiles() + }.getOrNull()?.filterNotNull() ?: scopeCatching { + Files.list(this.toPath()).collect(Collectors.toList()).mapNotNull { path -> + path?.toFile() + } + }.getOrNull() ?: emptyList() +} + +fun File.mkdirsSafely(): Boolean = scopeCatching { + this.mkdirs() +}.getOrNull() ?: false + +fun File.deleteSafely(): Boolean { + return scopeCatching { + Files.delete(this.toPath()) + }.isSuccess || scopeCatching { + this.delete() + }.getOrNull() ?: false +} + +fun File.move(name: String): File { + return scopeCatching { + Files.move(this.toPath(), File(this.parent, name).toPath()) + }.getOrNull()?.toFile() ?: scopeCatching { + val targetFile = File(this.parent, name) + if (this.renameTo(targetFile)) { + targetFile + } else { + null + } + }.getOrNull() ?: this +} + +fun findSystemRoots(): List { + val windowsRoot = systemEnv("SystemDrive") + val roots = (scopeCatching { + FileSystems.getDefault()?.rootDirectories?.mapNotNull { + it?.toFile() + } + }.getOrNull()?.ifEmpty { null } ?: scopeCatching { + File.listRoots().filterNotNull() + }.getOrNull()?.toList()?.ifEmpty { null } ?: emptyList()).normalize() + + return (if (!windowsRoot.isNullOrBlank()) { + roots.sortedByDescending { + it.canonicalPath.trim().equals(windowsRoot, true) || it.isSame(File(windowsRoot)) + } + } else { + roots + }) +} + +fun File.isDirectorySafely(): Boolean { + return scopeCatching { + this.isDirectory + }.getOrNull() ?: scopeCatching { + Files.isDirectory(this.toPath()) + }.getOrNull() ?: false +} + +fun File.parentSafely(): File? { + return scopeCatching { + this.toPath().parent?.toFile() + }.getOrNull() ?: scopeCatching { + this.parentFile + }.getOrNull() +} + +fun Collection.existsSafely(): List { + return this.mapNotNull { + if (it.existsSafely()) { + it + } else { + null + } + } +} + +fun Collection.existsRSafely(): List { + return this.mapNotNull { + if (it.existsRSafely()) { + it + } else { + null + } + } +} + +fun Collection.existsRWSafely(): List { + return this.mapNotNull { + if (it.existsRWSafely()) { + it + } else { + null + } + } +} \ No newline at end of file diff --git a/model/src/javaMain/kotlin/dev/datlag/burningseries/model/common/ExtendSystem.kt b/model/src/javaMain/kotlin/dev/datlag/burningseries/model/common/ExtendSystem.kt new file mode 100644 index 00000000..d13e8104 --- /dev/null +++ b/model/src/javaMain/kotlin/dev/datlag/burningseries/model/common/ExtendSystem.kt @@ -0,0 +1,25 @@ +package dev.datlag.burningseries.model.common + +import java.io.File + +fun systemProperty(key: String): String? = scopeCatching { + System.getProperty(key).ifEmpty { + null + } +}.getOrNull() + +fun systemEnv(key: String): String? = scopeCatching { + System.getenv(key).ifEmpty { + null + } +}.getOrNull() + +fun homeDirectory(): File? { + return systemProperty("user.home")?.let { + File(it) + } ?: systemEnv("HOME")?.let { + File(it) + } ?: systemEnv("\$HOME")?.let { + File(it) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 98de0024..951692c9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,5 +15,6 @@ pluginManagement { gradlePluginPortal() maven { url = uri("https://jitpack.io") } maven { url = uri("https://plugins.gradle.org/m2/") } + maven("https://jogamp.org/deployment/maven") } }