diff --git a/build-logic/convention/src/main/kotlin/KotlinMultiplatformLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KotlinMultiplatformLibraryConventionPlugin.kt index 1245fb91..50e4d25b 100644 --- a/build-logic/convention/src/main/kotlin/KotlinMultiplatformLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/KotlinMultiplatformLibraryConventionPlugin.kt @@ -27,6 +27,7 @@ class KotlinMultiplatformLibraryConventionPlugin : Plugin { addMacosTargets = true, addWatchosTargets = path == ":filekit-core", addMingwTargets = path == ":filekit-core" || path == ":filekit-dialogs", + addLinuxTargets = path == ":filekit-core", ) } } diff --git a/build-logic/convention/src/main/kotlin/io/github/vinceglb/filekit/convention/ConfigureKotlinMultiplatform.kt b/build-logic/convention/src/main/kotlin/io/github/vinceglb/filekit/convention/ConfigureKotlinMultiplatform.kt index cd82b214..a9127a74 100644 --- a/build-logic/convention/src/main/kotlin/io/github/vinceglb/filekit/convention/ConfigureKotlinMultiplatform.kt +++ b/build-logic/convention/src/main/kotlin/io/github/vinceglb/filekit/convention/ConfigureKotlinMultiplatform.kt @@ -14,6 +14,7 @@ internal fun Project.configureKotlinMultiplatform( addMacosTargets: Boolean, addWatchosTargets: Boolean, addMingwTargets: Boolean = false, + addLinuxTargets: Boolean = false, ) = extension.apply { // Force visibility of public API explicitApi() @@ -47,6 +48,12 @@ internal fun Project.configureKotlinMultiplatform( mingwX64() } + // Linux native targets + if (addLinuxTargets) { + linuxX64() + linuxArm64() + } + // If one day we need to disable watchOS tests // // if (addWatchosTargets) { diff --git a/filekit-core/build.gradle.kts b/filekit-core/build.gradle.kts index f082be6d..27a40fe5 100644 --- a/filekit-core/build.gradle.kts +++ b/filekit-core/build.gradle.kts @@ -11,6 +11,7 @@ kotlin { jvmMain.get().dependsOn(desktopMain) macosMain.get().dependsOn(desktopMain) mingwX64Main.get().dependsOn(desktopMain) + findByName("linuxMain")?.dependsOn(desktopMain) jvmMain.get().dependsOn(jvmAndNativeMain) nativeMain.get().dependsOn(jvmAndNativeMain) diff --git a/filekit-core/src/linuxMain/kotlin/io/github/vinceglb/filekit/FileKit.linux.kt b/filekit-core/src/linuxMain/kotlin/io/github/vinceglb/filekit/FileKit.linux.kt new file mode 100644 index 00000000..efd2bdb0 --- /dev/null +++ b/filekit-core/src/linuxMain/kotlin/io/github/vinceglb/filekit/FileKit.linux.kt @@ -0,0 +1,183 @@ +package io.github.vinceglb.filekit + +import io.github.vinceglb.filekit.exceptions.FileKitException +import io.github.vinceglb.filekit.utils.runSuspendCatchingFileKit +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.withContext +import kotlinx.io.Source +import kotlinx.io.buffered +import kotlinx.io.readString +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.cinterop.toKString +import platform.posix.getenv + +public actual object FileKit { + private var _appId: String? = null + internal var customCacheDir: Path? = null + internal var customFilesDir: Path? = null + + public val appId: String + get() = _appId + ?: throw FileKitException("FileKit not initialized. Please call FileKit.init(appId) first.") + + public fun init(appId: String) { + _appId = appId + customCacheDir = null + customFilesDir = null + } + + public fun init( + filesDir: PlatformFile, + cacheDir: PlatformFile, + ) { + _appId = null + customCacheDir = cacheDir.toKotlinxIoPath() + customFilesDir = filesDir.toKotlinxIoPath() + } + + public fun init( + appId: String, + filesDir: PlatformFile? = null, + cacheDir: PlatformFile? = null, + ) { + _appId = appId + customCacheDir = cacheDir?.toKotlinxIoPath() + customFilesDir = filesDir?.toKotlinxIoPath() + } +} + +public actual val FileKit.filesDir: PlatformFile + get() { + val folder = FileKit.customFilesDir + ?: (getEnv("XDG_DATA_HOME")?.let { Path(it) } ?: (getEnv("HOME")?.let { Path(it, ".local/share") } ?: Path(".local/share"))) / FileKit.appId + folder.assertExists() + return PlatformFile(folder) + } + +public actual val FileKit.cacheDir: PlatformFile + get() { + val folder = FileKit.customCacheDir + ?: (getEnv("XDG_CACHE_HOME")?.let { Path(it) } ?: (getEnv("HOME")?.let { Path(it, ".cache") } ?: Path(".cache"))) / FileKit.appId + folder.assertExists() + return PlatformFile(folder) + } + +public actual val FileKit.databasesDir: PlatformFile + get() = FileKit.filesDir / "databases" + +public actual val FileKit.projectDir: PlatformFile + get() = PlatformFile(".") + +@OptIn(ExperimentalForeignApi::class) +internal actual fun FileKit.platformUserDirectoryOrNull(type: FileKitUserDirectory): PlatformFile? { + val home = getEnv("HOME") ?: return null + + val envKey = when (type) { + FileKitUserDirectory.Downloads -> "XDG_DOWNLOAD_DIR" + FileKitUserDirectory.Pictures -> "XDG_PICTURES_DIR" + FileKitUserDirectory.Videos -> "XDG_VIDEOS_DIR" + FileKitUserDirectory.Music -> "XDG_MUSIC_DIR" + FileKitUserDirectory.Documents -> "XDG_DOCUMENTS_DIR" + } + + val fallbackName = when (type) { + FileKitUserDirectory.Downloads -> "Downloads" + FileKitUserDirectory.Pictures -> "Pictures" + FileKitUserDirectory.Videos -> "Videos" + FileKitUserDirectory.Music -> "Music" + FileKitUserDirectory.Documents -> "Documents" + } + + val envValue = getEnv(envKey)?.takeIf(String::isNotBlank)?.let { expandHomeVariable(it, home) } + if (envValue != null) { + val path = Path(envValue) + path.assertExists() + return PlatformFile(path) + } + + // Try reading ~/.config/user-dirs.dirs for the actual config (simplistic parsing) + val configHome = getEnv("XDG_CONFIG_HOME")?.takeIf(String::isNotBlank) ?: "$home/.config" + val configFile = Path(configHome, "user-dirs.dirs") + if (SystemFileSystem.exists(configFile)) { + val content = runCatching { SystemFileSystem.source(configFile).buffered().use { it.readString() } }.getOrNull() + if (content != null) { + val configuredValue = parseXdgUserDirsConfig(content)[envKey]?.takeIf(String::isNotBlank)?.let { expandHomeVariable(it, home) } + if (configuredValue != null) { + val path = Path(configuredValue) + path.assertExists() + return PlatformFile(path) + } + } + } + + val fallbackPath = Path(home, fallbackName) + fallbackPath.assertExists() + return PlatformFile(fallbackPath) +} + +public actual suspend fun FileKit.saveImageToGallery( + bytes: ByteArray, + filename: String, +): Result = runSuspendCatchingFileKit { + FileKit.picturesDir / filename write bytes +} + +public actual suspend fun FileKit.saveVideoToGallery( + file: PlatformFile, + filename: String, +): Result = runSuspendCatchingFileKit { + FileKit.videosDir / filename write file +} + +public actual suspend fun FileKit.compressImage( + bytes: ByteArray, + imageFormat: ImageFormat, + @androidx.annotation.IntRange(from = 0, to = 100) quality: Int, + maxWidth: Int?, + maxHeight: Int?, +): ByteArray = + throw FileKitException("Image compression is not supported on Linux native target") + +@OptIn(ExperimentalForeignApi::class) +private fun getEnv(key: String): String? = + getenv(key)?.toKString() + +private operator fun Path.div(child: String): Path = Path(this, child) + +private fun Path.assertExists() { + if (!SystemFileSystem.exists(this)) { + SystemFileSystem.createDirectories(this) + } +} + +private fun expandHomeVariable(path: String, home: String): String = + path + .replace("\${HOME}", home) + .replace("\$HOME", home) + +private fun parseXdgUserDirsConfig(config: String): Map = + buildMap { + config + .lineSequence() + .map(String::trim) + .filter(String::isNotBlank) + .filterNot { it.startsWith("#") } + .forEach { line -> + val key = line.substringBefore("=", missingDelimiterValue = "").trim() + val rawValue = line.substringAfter("=", missingDelimiterValue = "").trim() + + if (key.isBlank() || rawValue.isBlank()) { + return@forEach + } + + val value = rawValue + .removeSurrounding("\"") + .replace("\\\"", "\"") + .replace("\\\\", "\\") + + put(key, value) + } + } + + diff --git a/filekit-core/src/linuxMain/kotlin/io/github/vinceglb/filekit/PlatformFile.linux.kt b/filekit-core/src/linuxMain/kotlin/io/github/vinceglb/filekit/PlatformFile.linux.kt new file mode 100644 index 00000000..42fe56f9 --- /dev/null +++ b/filekit-core/src/linuxMain/kotlin/io/github/vinceglb/filekit/PlatformFile.linux.kt @@ -0,0 +1,118 @@ +package io.github.vinceglb.filekit + +import io.github.vinceglb.filekit.mimeType.MimeType +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.withContext +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.serialization.Serializable +import platform.posix.stat +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** + * Wrapper for a file path on Linux platform. + */ +public class LinuxPath( + public val path: Path, +) + +/** + * Represents a file on the Linux platform. + * + * @property linuxPath The underlying wrapped [Path] object. + */ +@Serializable(with = PlatformFileSerializer::class) +public actual class PlatformFile( + public val linuxPath: LinuxPath, +) { + public actual override fun toString(): String = linuxPath.path.toString() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PlatformFile) return false + return linuxPath.path.toString() == other.linuxPath.path.toString() + } + + override fun hashCode(): Int = linuxPath.path.toString().hashCode() + + public actual companion object +} + +public actual fun PlatformFile(path: Path): PlatformFile = + PlatformFile(linuxPath = LinuxPath(path)) + +public actual fun PlatformFile.toKotlinxIoPath(): Path = + linuxPath.path + +public actual val PlatformFile.extension: String + get() = name.substringAfterLast('.', "") + +public actual val PlatformFile.nameWithoutExtension: String + get() = name.substringBeforeLast('.', name) + +public actual fun PlatformFile.absolutePath(): String { + val rawPath = linuxPath.path.toString() + if (rawPath.startsWith("/")) return rawPath + return absoluteFile().linuxPath.path.toString() +} + +public actual inline fun PlatformFile.list(block: (List) -> Unit): Unit = + withScopedAccess { + val directoryFiles = SystemFileSystem + .list(toKotlinxIoPath()) + .map { PlatformFile(it) } + block(directoryFiles) + } + +public actual fun PlatformFile.list(): List = + withScopedAccess { + SystemFileSystem + .list(toKotlinxIoPath()) + .map { PlatformFile(it) } + } + +@OptIn(ExperimentalForeignApi::class, ExperimentalTime::class) +public actual fun PlatformFile.createdAt(): Instant? = memScoped { + val statBuf = alloc() + if (platform.posix.stat(absolutePath(), statBuf.ptr) == 0) { + Instant.fromEpochSeconds(statBuf.st_ctim.tv_sec.toLong(), statBuf.st_ctim.tv_nsec.toLong()) + } else { + null + } +} + +@OptIn(ExperimentalForeignApi::class, ExperimentalTime::class) +public actual fun PlatformFile.lastModified(): Instant = memScoped { + val statBuf = alloc() + if (platform.posix.stat(absolutePath(), statBuf.ptr) == 0) { + Instant.fromEpochSeconds(statBuf.st_mtim.tv_sec.toLong(), statBuf.st_mtim.tv_nsec.toLong()) + } else { + Instant.fromEpochMilliseconds(0L) + } +} + +public actual fun PlatformFile.mimeType(): MimeType? = null + +public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean = true + +public actual fun PlatformFile.stopAccessingSecurityScopedResource() {} + +public actual suspend fun PlatformFile.bookmarkData(): BookmarkData = + withContext(Dispatchers.IO) { + BookmarkData(absolutePath().encodeToByteArray()) + } + +public actual fun PlatformFile.releaseBookmark() {} + +public actual fun PlatformFile.Companion.fromBookmarkData( + bookmarkData: BookmarkData, +): PlatformFile { + val restoredPath = bookmarkData.bytes.decodeToString() + return PlatformFile(Path(restoredPath)) +}