From 82c705c11fc6e84f7d07f03c63290822df8e6c86 Mon Sep 17 00:00:00 2001 From: Nikunj kumar Date: Fri, 24 Jul 2026 21:24:25 +0530 Subject: [PATCH 1/5] Add linuxX64 and linuxArm64 support to filekit-core --- ...linMultiplatformLibraryConventionPlugin.kt | 1 + .../ConfigureKotlinMultiplatform.kt | 7 + filekit-core/build.gradle.kts | 1 + .../github/vinceglb/filekit/FileKit.linux.kt | 183 ++++++++++++++++++ .../vinceglb/filekit/PlatformFile.linux.kt | 118 +++++++++++ 5 files changed, 310 insertions(+) create mode 100644 filekit-core/src/linuxMain/kotlin/io/github/vinceglb/filekit/FileKit.linux.kt create mode 100644 filekit-core/src/linuxMain/kotlin/io/github/vinceglb/filekit/PlatformFile.linux.kt 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)) +} From ed0172c4e7a4db740449cc251d4c4f929ad80816 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Wed, 29 Jul 2026 15:50:28 +0200 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=90=9B=20Fix=20Linux=20native=20absol?= =?UTF-8?q?utePath,=20MIME=20type=20lookup=20and=20lint=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - absolutePath() no longer routes through SystemFileSystem.resolve, which threw FileNotFoundException for relative paths of files that do not exist. This broke `write`/`copyTo` on any relative destination, since isSameLogicalFileAs() calls absolutePath() before the file is created. It now resolves against getcwd(), matching JVM and Windows native. - mimeType() returned null unconditionally. It now reads the freedesktop.org shared-mime-info database, falling back to /etc/mime.types, cached lazily. - Fix ktlint violations that were failing the CI lint job (import ordering, argument list wrapping, max line length, consecutive blank lines). - Drop redundant .toLong() conversions on st_ctim/st_mtim and the unused withContext import; document why st_ctim is used for createdAt(). - Honour the XDG Base Directory rule that XDG_* variables are ignored unless they hold an absolute path. - Replace findByName("linuxMain")?.dependsOn(...) with linuxMain.get() so a missing source set fails loudly instead of silently skipping desktopMain. - Add a linuxX64Test CI job so the new targets are actually exercised, plus Linux specific absolutePath() regression tests. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 20 +++ filekit-core/build.gradle.kts | 2 +- .../github/vinceglb/filekit/FileKit.linux.kt | 68 ++++++---- .../vinceglb/filekit/PlatformFile.linux.kt | 119 ++++++++++++++++-- .../vinceglb/filekit/PlatformFileLinuxTest.kt | 45 +++++++ 5 files changed, 222 insertions(+), 32 deletions(-) create mode 100644 filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/PlatformFileLinuxTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cecb08c2..5c14b9d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,6 +134,26 @@ jobs: - name: ๐Ÿงช Run macOS Tests run: ./gradlew macosArm64Test --no-daemon + test-linux: + name: ๐Ÿง Test Linux + needs: build + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ›Ž๏ธ Check out repository + uses: actions/checkout@v6 + + - name: ๐Ÿ‰ Configure JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '21' + + - name: ๐Ÿ˜ Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: ๐Ÿงช Run Linux Native Tests + run: ./gradlew linuxX64Test + lint: name: ๐Ÿšจ Lint runs-on: ubuntu-latest diff --git a/filekit-core/build.gradle.kts b/filekit-core/build.gradle.kts index 27a40fe5..65b685fc 100644 --- a/filekit-core/build.gradle.kts +++ b/filekit-core/build.gradle.kts @@ -11,7 +11,7 @@ kotlin { jvmMain.get().dependsOn(desktopMain) macosMain.get().dependsOn(desktopMain) mingwX64Main.get().dependsOn(desktopMain) - findByName("linuxMain")?.dependsOn(desktopMain) + linuxMain.get().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 index efd2bdb0..fb9d1e80 100644 --- 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 @@ -3,13 +3,11 @@ 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.cinterop.toKString import kotlinx.io.buffered -import kotlinx.io.readString import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem -import kotlinx.cinterop.toKString +import kotlinx.io.readString import platform.posix.getenv public actual object FileKit { @@ -50,7 +48,7 @@ public actual object FileKit { 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 + ?: (xdgBaseDirectory(envKey = "XDG_DATA_HOME", homeRelativeFallback = ".local/share") / FileKit.appId) folder.assertExists() return PlatformFile(folder) } @@ -58,7 +56,7 @@ public actual val FileKit.filesDir: PlatformFile 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 + ?: (xdgBaseDirectory(envKey = "XDG_CACHE_HOME", homeRelativeFallback = ".cache") / FileKit.appId) folder.assertExists() return PlatformFile(folder) } @@ -69,9 +67,8 @@ public actual val FileKit.databasesDir: PlatformFile 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 home = getEnv("HOME")?.takeIf(String::isNotBlank) ?: return null val envKey = when (type) { FileKitUserDirectory.Downloads -> "XDG_DOWNLOAD_DIR" @@ -89,28 +86,27 @@ internal actual fun FileKit.platformUserDirectoryOrNull(type: FileKitUserDirecto FileKitUserDirectory.Documents -> "Documents" } - val envValue = getEnv(envKey)?.takeIf(String::isNotBlank)?.let { expandHomeVariable(it, home) } + // Primary: the XDG user directory environment variable + 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) - } - } + // Fallback: the xdg-user-dirs configuration file written by xdg-user-dirs-update + val configuredValue = readXdgUserDirsConfig(home)[envKey] + ?.takeIf(String::isNotBlank) + ?.let { expandHomeVariable(it, home) } + if (configuredValue != null) { + val path = Path(configuredValue) + path.assertExists() + return PlatformFile(path) } + // Last resort: the default English folder name inside HOME (same as JVM) val fallbackPath = Path(home, fallbackName) fallbackPath.assertExists() return PlatformFile(fallbackPath) @@ -143,6 +139,20 @@ public actual suspend fun FileKit.compressImage( private fun getEnv(key: String): String? = getenv(key)?.toKString() +private fun xdgBaseDirectory( + envKey: String, + homeRelativeFallback: String, +): Path { + // The XDG Base Directory spec requires the variable to be ignored unless it holds an absolute path + getEnv(envKey) + ?.takeIf { it.startsWith("/") } + ?.let { return Path(it) } + + val home = getEnv("HOME")?.takeIf(String::isNotBlank) + ?: return Path(homeRelativeFallback) + return Path(home, homeRelativeFallback) +} + private operator fun Path.div(child: String): Path = Path(this, child) private fun Path.assertExists() { @@ -156,6 +166,18 @@ private fun expandHomeVariable(path: String, home: String): String = .replace("\${HOME}", home) .replace("\$HOME", home) +private fun readXdgUserDirsConfig(home: String): Map { + val configHome = getEnv("XDG_CONFIG_HOME")?.takeIf { it.startsWith("/") } ?: "$home/.config" + val configFile = Path(configHome, "user-dirs.dirs") + if (!SystemFileSystem.exists(configFile)) return emptyMap() + + val content = runCatching { + SystemFileSystem.source(configFile).buffered().use { it.readString() } + }.getOrNull() ?: return emptyMap() + + return parseXdgUserDirsConfig(content) +} + private fun parseXdgUserDirsConfig(config: String): Map = buildMap { config @@ -179,5 +201,3 @@ private fun parseXdgUserDirsConfig(config: String): Map = 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 index 42fe56f9..e0d17c01 100644 --- 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 @@ -1,16 +1,23 @@ package io.github.vinceglb.filekit import io.github.vinceglb.filekit.mimeType.MimeType +import kotlinx.cinterop.ByteVar import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.alloc +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.convert import kotlinx.cinterop.memScoped import kotlinx.cinterop.ptr +import kotlinx.cinterop.toKString import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.withContext +import kotlinx.io.buffered import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem +import kotlinx.io.readString import kotlinx.serialization.Serializable +import platform.posix.getcwd import platform.posix.stat import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -59,7 +66,22 @@ public actual val PlatformFile.nameWithoutExtension: String public actual fun PlatformFile.absolutePath(): String { val rawPath = linuxPath.path.toString() if (rawPath.startsWith("/")) return rawPath - return absoluteFile().linuxPath.path.toString() + + // Resolve against the working directory without requiring the file to exist, + // matching the JVM and Windows native behaviour. + val workingDirectory = currentWorkingDirectory() ?: return rawPath + return when { + rawPath.isEmpty() -> workingDirectory + workingDirectory.endsWith("/") -> "$workingDirectory$rawPath" + else -> "$workingDirectory/$rawPath" + } +} + +@OptIn(ExperimentalForeignApi::class) +private fun currentWorkingDirectory(): String? = memScoped { + val bufferSize = 4096 + val buffer = allocArray(bufferSize) + getcwd(buffer, bufferSize.convert())?.toKString() } public actual inline fun PlatformFile.list(block: (List) -> Unit): Unit = @@ -77,11 +99,16 @@ public actual fun PlatformFile.list(): List = .map { PlatformFile(it) } } +/** + * Linux has no portable way to read a file's birth time: `statx(STATX_BTIME)` is not exposed by + * Kotlin/Native and is unsupported by several filesystems. The inode change time (`st_ctim`) is + * used as a best-effort approximation, which is what most Linux tooling reports as well. + */ @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()) + if (stat(absolutePath(), statBuf.ptr) == 0) { + Instant.fromEpochSeconds(statBuf.st_ctim.tv_sec, statBuf.st_ctim.tv_nsec) } else { null } @@ -90,14 +117,92 @@ public actual fun PlatformFile.createdAt(): Instant? = memScoped { @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()) + if (stat(absolutePath(), statBuf.ptr) == 0) { + Instant.fromEpochSeconds(statBuf.st_mtim.tv_sec, statBuf.st_mtim.tv_nsec) } else { Instant.fromEpochMilliseconds(0L) } } -public actual fun PlatformFile.mimeType(): MimeType? = null +public actual fun PlatformFile.mimeType(): MimeType? { + val ext = extension.lowercase() + if (ext.isBlank()) return null + return systemMimeTypesByExtension[ext] +} + +private const val SHARED_MIME_INFO_GLOBS = "/usr/share/mime/globs2" +private const val MIME_TYPES_DATABASE = "/etc/mime.types" + +/** + * Extension to MIME type mapping read once from the system MIME databases. + * + * The freedesktop.org shared-mime-info database is preferred and the Apache style `mime.types` + * file is used as a fallback. Both are absent on minimal systems, in which case the mapping is + * empty and [mimeType] returns null. + */ +private val systemMimeTypesByExtension: Map by lazy { + parseSharedMimeInfoGlobs() + .takeIf { it.isNotEmpty() } + ?: parseMimeTypesDatabase() +} + +/** + * Parses the freedesktop.org shared-mime-info database. + * + * `globs2` lines look like `50:text/plain:*.txt`, ordered by descending weight. + */ +private fun parseSharedMimeInfoGlobs(): Map = + buildMap { + readSystemFileOrNull(SHARED_MIME_INFO_GLOBS) + ?.lineSequence() + ?.filterNot { it.isBlank() || it.startsWith("#") } + ?.forEach { line -> + val parts = line.split(':') + if (parts.size < 3) return@forEach + + val glob = parts[2].trim() + // Only simple `*.ext` globs map to a single extension + if (!glob.startsWith("*.") || glob.count { it == '.' } != 1) return@forEach + + val extension = glob.removePrefix("*.").lowercase() + val mimeType = parseMimeTypeOrNull(parts[1].trim()) ?: return@forEach + + // Entries are weight ordered, so the first match wins + getOrPut(extension) { mimeType } + } + } + +/** + * Parses an Apache style `mime.types` database. + * + * Lines look like `text/plain txt text`. + */ +private fun parseMimeTypesDatabase(): Map = + buildMap { + readSystemFileOrNull(MIME_TYPES_DATABASE) + ?.lineSequence() + ?.filterNot { it.isBlank() || it.startsWith("#") } + ?.forEach { line -> + val tokens = line.split(' ', '\t').filter(String::isNotBlank) + if (tokens.size < 2) return@forEach + + val mimeType = parseMimeTypeOrNull(tokens[0]) ?: return@forEach + tokens.drop(1).forEach { extension -> + getOrPut(extension.lowercase()) { mimeType } + } + } + } + +private fun parseMimeTypeOrNull(value: String): MimeType? = + runCatching { MimeType.parse(value) }.getOrNull() + +private fun readSystemFileOrNull(location: String): String? { + val path = Path(location) + if (!SystemFileSystem.exists(path)) return null + return runCatching { + SystemFileSystem.source(path).buffered().use { it.readString() } + }.getOrNull() +} public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean = true @@ -114,5 +219,5 @@ public actual fun PlatformFile.Companion.fromBookmarkData( bookmarkData: BookmarkData, ): PlatformFile { val restoredPath = bookmarkData.bytes.decodeToString() - return PlatformFile(Path(restoredPath)) + return PlatformFile(linuxPath = LinuxPath(Path(restoredPath))) } diff --git a/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/PlatformFileLinuxTest.kt b/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/PlatformFileLinuxTest.kt new file mode 100644 index 00000000..4e7e2740 --- /dev/null +++ b/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/PlatformFileLinuxTest.kt @@ -0,0 +1,45 @@ +package io.github.vinceglb.filekit + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PlatformFileLinuxTest { + @Test + fun absolutePath_absolutePath_isReturnedUnchanged() { + assertEquals( + expected = "/tmp/filekit/hello.txt", + actual = PlatformFile("/tmp/filekit/hello.txt").absolutePath(), + ) + } + + @Test + fun absolutePath_relativePathOfMissingFile_resolvesWithoutThrowing() { + val absolutePath = PlatformFile("does-not-exist/hello.txt").absolutePath() + + assertTrue(absolutePath.startsWith("/"), "Expected an absolute path but got $absolutePath") + assertTrue(absolutePath.endsWith("does-not-exist/hello.txt"), "Unexpected path $absolutePath") + } + + @Test + fun absolutePath_relativePathOfExistingFile_resolvesToSameParentAsMissingSibling() { + val existing = FileKit.projectDir / "src/nonWebTest/resources/hello.txt" + val missing = FileKit.projectDir / "src/nonWebTest/resources/not-existing-file.pdf" + + val existingParent = existing.absolutePath().substringBeforeLast('/') + val missingParent = missing.absolutePath().substringBeforeLast('/') + + assertEquals(expected = existingParent, actual = missingParent) + } + + @Test + fun absolutePath_emptyPath_resolvesToWorkingDirectory() { + val absolutePath = PlatformFile("").absolutePath() + + assertTrue(absolutePath.startsWith("/"), "Expected an absolute path but got $absolutePath") + assertTrue( + absolutePath == "/" || !absolutePath.endsWith("/"), + "Working directory should not have a trailing slash: $absolutePath", + ) + } +} From 38f2b0837da558710c5b2d7270d7d53700342c02 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Wed, 29 Jul 2026 17:03:07 +0200 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=90=A7=20Address=20review:=20restore?= =?UTF-8?q?=20Linux=20actuals,=20drop=20fake=20createdAt,=20expand=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto main (80b901d0), which added two expect declarations in #625 that Linux had no actual for, so the merge no longer compiled: - withPath() in jvmAndNativeMain - resolveBookmarkData() in nonWebMain, replacing the old fromBookmarkData shape Both are now implemented, mirroring mingwX64. createdAt() no longer reports st_ctim. That is the inode status change time and is updated by writes and chmod, so it was reporting a fabricated creation time. Linux only exposes birth time via statx(STATX_BTIME), which Kotlin/Native does not bind and which several filesystems do not support, so null is returned. See https://man7.org/linux/man-pages/man7/inode.7.html MIME lookup now honours the globs2 `cs` flag, so case sensitive entries such as `*.C:cs` (C++ source) no longer collide with `*.c` (C source). Compound globs like `*.tar.gz` stay excluded on purpose: `extension` is the segment after the last dot, so registering them under "gz" would shadow the correct entry. filesDir/cacheDir no longer fall back to relative `.local/share` and `.cache` when HOME is unset, which could plant application data in the working directory. The passwd database is consulted first and a FileKitException is thrown if no absolute home can be resolved. Also: - Make the globs2 and mime.types parsers pure functions over their content so the matching rules are unit testable rather than host dependent. - Collapse the two duplicated FileKitUserDirectory switches into extension properties so XDG keys and folder names cannot drift. - Expand linuxTest to 25 tests covering timestamps, bookmarks, MIME matching, FileKit initialization and directory resolution; rename to the documented Subject_action_expectation convention. Co-Authored-By: Claude Opus 5 (1M context) --- .../github/vinceglb/filekit/FileKit.linux.kt | 44 +++- .../vinceglb/filekit/PlatformFile.linux.kt | 163 +++++++++----- .../vinceglb/filekit/FileKitLinuxTest.kt | 117 ++++++++++ .../vinceglb/filekit/PlatformFileLinuxTest.kt | 212 +++++++++++++++++- 4 files changed, 461 insertions(+), 75 deletions(-) create mode 100644 filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/FileKitLinuxTest.kt 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 index fb9d1e80..ca13fa5e 100644 --- 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 @@ -3,12 +3,15 @@ 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.cinterop.pointed import kotlinx.cinterop.toKString import kotlinx.io.buffered import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem import kotlinx.io.readString import platform.posix.getenv +import platform.posix.getpwuid +import platform.posix.getuid public actual object FileKit { private var _appId: String? = null @@ -67,10 +70,12 @@ public actual val FileKit.databasesDir: PlatformFile public actual val FileKit.projectDir: PlatformFile get() = PlatformFile(".") -internal actual fun FileKit.platformUserDirectoryOrNull(type: FileKitUserDirectory): PlatformFile? { - val home = getEnv("HOME")?.takeIf(String::isNotBlank) ?: return null - - val envKey = when (type) { +/** + * The XDG variable name and default folder name for each user directory, kept together so the two + * cannot drift apart. + */ +private val FileKitUserDirectory.xdgEnvKey: String + get() = when (this) { FileKitUserDirectory.Downloads -> "XDG_DOWNLOAD_DIR" FileKitUserDirectory.Pictures -> "XDG_PICTURES_DIR" FileKitUserDirectory.Videos -> "XDG_VIDEOS_DIR" @@ -78,7 +83,8 @@ internal actual fun FileKit.platformUserDirectoryOrNull(type: FileKitUserDirecto FileKitUserDirectory.Documents -> "XDG_DOCUMENTS_DIR" } - val fallbackName = when (type) { +private val FileKitUserDirectory.defaultFolderName: String + get() = when (this) { FileKitUserDirectory.Downloads -> "Downloads" FileKitUserDirectory.Pictures -> "Pictures" FileKitUserDirectory.Videos -> "Videos" @@ -86,6 +92,11 @@ internal actual fun FileKit.platformUserDirectoryOrNull(type: FileKitUserDirecto FileKitUserDirectory.Documents -> "Documents" } +internal actual fun FileKit.platformUserDirectoryOrNull(type: FileKitUserDirectory): PlatformFile? { + val home = homeDirectoryOrNull() ?: return null + val envKey = type.xdgEnvKey + val fallbackName = type.defaultFolderName + // Primary: the XDG user directory environment variable val envValue = getEnv(envKey) ?.takeIf(String::isNotBlank) @@ -148,11 +159,30 @@ private fun xdgBaseDirectory( ?.takeIf { it.startsWith("/") } ?.let { return Path(it) } - val home = getEnv("HOME")?.takeIf(String::isNotBlank) - ?: return Path(homeRelativeFallback) + val home = homeDirectoryOrNull() ?: throw FileKitException( + "Could not resolve the home directory. Set HOME or call FileKit.init(appId, filesDir, cacheDir) " + + "with explicit directories.", + ) return Path(home, homeRelativeFallback) } +/** + * Resolves the user's home directory from `HOME`, falling back to the passwd database so that + * daemons and containers with an unset environment still get an absolute path. + */ +@OptIn(ExperimentalForeignApi::class) +private fun homeDirectoryOrNull(): String? { + getEnv("HOME") + ?.takeIf { it.startsWith("/") } + ?.let { return it } + + return getpwuid(getuid()) + ?.pointed + ?.pw_dir + ?.toKString() + ?.takeIf { it.startsWith("/") } +} + private operator fun Path.div(child: String): Path = Path(this, child) private fun Path.assertExists() { 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 index e0d17c01..6112a759 100644 --- 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 @@ -57,6 +57,9 @@ public actual fun PlatformFile(path: Path): PlatformFile = public actual fun PlatformFile.toKotlinxIoPath(): Path = linuxPath.path +@PublishedApi +internal actual fun PlatformFile.withPath(path: Path): PlatformFile = PlatformFile(path) + public actual val PlatformFile.extension: String get() = name.substringAfterLast('.', "") @@ -100,19 +103,17 @@ public actual fun PlatformFile.list(): List = } /** - * Linux has no portable way to read a file's birth time: `statx(STATX_BTIME)` is not exposed by - * Kotlin/Native and is unsupported by several filesystems. The inode change time (`st_ctim`) is - * used as a best-effort approximation, which is what most Linux tooling reports as well. + * Always returns null on Linux. + * + * `stat` exposes no creation time. Its `st_ctim` field is the inode *status change* time, which is + * updated by writes and metadata changes, so reporting it as a creation time would be wrong. + * Birth time is only available through `statx(STATX_BTIME)`, which Kotlin/Native does not expose + * and which several filesystems do not support, so null is the honest answer here. + * + * See https://man7.org/linux/man-pages/man7/inode.7.html */ -@OptIn(ExperimentalForeignApi::class, ExperimentalTime::class) -public actual fun PlatformFile.createdAt(): Instant? = memScoped { - val statBuf = alloc() - if (stat(absolutePath(), statBuf.ptr) == 0) { - Instant.fromEpochSeconds(statBuf.st_ctim.tv_sec, statBuf.st_ctim.tv_nsec) - } else { - null - } -} +@OptIn(ExperimentalTime::class) +public actual fun PlatformFile.createdAt(): Instant? = null @OptIn(ExperimentalForeignApi::class, ExperimentalTime::class) public actual fun PlatformFile.lastModified(): Instant = memScoped { @@ -124,74 +125,110 @@ public actual fun PlatformFile.lastModified(): Instant = memScoped { } } -public actual fun PlatformFile.mimeType(): MimeType? { - val ext = extension.lowercase() - if (ext.isBlank()) return null - return systemMimeTypesByExtension[ext] -} +public actual fun PlatformFile.mimeType(): MimeType? = + systemMimeTypes.find(extension) private const val SHARED_MIME_INFO_GLOBS = "/usr/share/mime/globs2" private const val MIME_TYPES_DATABASE = "/etc/mime.types" /** - * Extension to MIME type mapping read once from the system MIME databases. + * Extension to MIME type lookup, split by whether the source entry demanded a case sensitive match. + * + * shared-mime-info marks entries such as `*.C` (C++ source) with the `cs` flag to distinguish them + * from their case insensitive counterparts like `*.c` (C source), so the two cannot share a map. + */ +internal class SystemMimeTypes( + private val caseSensitive: Map, + private val caseInsensitive: Map, +) { + fun find(extension: String): MimeType? { + if (extension.isBlank()) return null + return caseSensitive[extension] ?: caseInsensitive[extension.lowercase()] + } + + fun isEmpty(): Boolean = caseSensitive.isEmpty() && caseInsensitive.isEmpty() +} + +/** + * MIME type mapping read once from the system MIME databases. * * The freedesktop.org shared-mime-info database is preferred and the Apache style `mime.types` * file is used as a fallback. Both are absent on minimal systems, in which case the mapping is * empty and [mimeType] returns null. */ -private val systemMimeTypesByExtension: Map by lazy { - parseSharedMimeInfoGlobs() - .takeIf { it.isNotEmpty() } - ?: parseMimeTypesDatabase() +private val systemMimeTypes: SystemMimeTypes by lazy { + readSystemFileOrNull(SHARED_MIME_INFO_GLOBS) + ?.let(::parseSharedMimeInfoGlobs) + ?.takeIf { !it.isEmpty() } + ?: readSystemFileOrNull(MIME_TYPES_DATABASE) + ?.let(::parseMimeTypesDatabase) + ?: SystemMimeTypes(caseSensitive = emptyMap(), caseInsensitive = emptyMap()) } /** * Parses the freedesktop.org shared-mime-info database. * - * `globs2` lines look like `50:text/plain:*.txt`, ordered by descending weight. + * `globs2` lines look like `weight:mime/type:glob[:flags]`, ordered by descending weight, for + * example `50:text/plain:*.txt` and `50:text/x-c++src:*.C:cs`. + * + * See https://specifications.freedesktop.org/shared-mime-info/latest-single/ */ -private fun parseSharedMimeInfoGlobs(): Map = - buildMap { - readSystemFileOrNull(SHARED_MIME_INFO_GLOBS) - ?.lineSequence() - ?.filterNot { it.isBlank() || it.startsWith("#") } - ?.forEach { line -> - val parts = line.split(':') - if (parts.size < 3) return@forEach - - val glob = parts[2].trim() - // Only simple `*.ext` globs map to a single extension - if (!glob.startsWith("*.") || glob.count { it == '.' } != 1) return@forEach - - val extension = glob.removePrefix("*.").lowercase() - val mimeType = parseMimeTypeOrNull(parts[1].trim()) ?: return@forEach - - // Entries are weight ordered, so the first match wins - getOrPut(extension) { mimeType } +internal fun parseSharedMimeInfoGlobs(content: String): SystemMimeTypes { + val caseSensitive = mutableMapOf() + val caseInsensitive = mutableMapOf() + + content + .lineSequence() + .filterNot { it.isBlank() || it.startsWith("#") } + .forEach { line -> + val parts = line.split(':') + if (parts.size < 3) return@forEach + + val glob = parts[2].trim() + + // Only single suffix `*.ext` globs are usable here. `extension` is the segment after + // the last dot, so a compound glob such as `*.tar.gz` can never be addressed by it and + // would shadow the correct `*.gz` entry if it were registered under "gz". + if (!glob.startsWith("*.") || glob.count { it == '.' } != 1) return@forEach + + val extension = glob.removePrefix("*.") + val mimeType = parseMimeTypeOrNull(parts[1].trim()) ?: return@forEach + val flags = parts.drop(3).map { it.trim() } + + // Entries are weight ordered, so the first one wins + if (flags.contains("cs")) { + caseSensitive.getOrPut(extension) { mimeType } + } else { + caseInsensitive.getOrPut(extension.lowercase()) { mimeType } } - } + } + + return SystemMimeTypes(caseSensitive = caseSensitive, caseInsensitive = caseInsensitive) +} /** - * Parses an Apache style `mime.types` database. + * Parses an Apache style `mime.types` database, which has no case sensitive entries. * * Lines look like `text/plain txt text`. */ -private fun parseMimeTypesDatabase(): Map = - buildMap { - readSystemFileOrNull(MIME_TYPES_DATABASE) - ?.lineSequence() - ?.filterNot { it.isBlank() || it.startsWith("#") } - ?.forEach { line -> - val tokens = line.split(' ', '\t').filter(String::isNotBlank) - if (tokens.size < 2) return@forEach - - val mimeType = parseMimeTypeOrNull(tokens[0]) ?: return@forEach - tokens.drop(1).forEach { extension -> - getOrPut(extension.lowercase()) { mimeType } - } +internal fun parseMimeTypesDatabase(content: String): SystemMimeTypes { + val caseInsensitive = mutableMapOf() + + content + .lineSequence() + .filterNot { it.isBlank() || it.startsWith("#") } + .forEach { line -> + val tokens = line.split(' ', '\t').filter(String::isNotBlank) + if (tokens.size < 2) return@forEach + + val mimeType = parseMimeTypeOrNull(tokens[0]) ?: return@forEach + tokens.drop(1).forEach { extension -> + caseInsensitive.getOrPut(extension.lowercase()) { mimeType } } - } + } + + return SystemMimeTypes(caseSensitive = emptyMap(), caseInsensitive = caseInsensitive) +} private fun parseMimeTypeOrNull(value: String): MimeType? = runCatching { MimeType.parse(value) }.getOrNull() @@ -217,7 +254,15 @@ public actual fun PlatformFile.releaseBookmark() {} public actual fun PlatformFile.Companion.fromBookmarkData( bookmarkData: BookmarkData, -): PlatformFile { +): PlatformFile = resolveBookmarkData(bookmarkData).file + +public actual fun PlatformFile.Companion.resolveBookmarkData( + bookmarkData: BookmarkData, +): BookmarkResolution { val restoredPath = bookmarkData.bytes.decodeToString() - return PlatformFile(linuxPath = LinuxPath(Path(restoredPath))) + return BookmarkResolution( + file = PlatformFile(linuxPath = LinuxPath(Path(restoredPath))), + isStale = false, + shouldRefresh = false, + ) } diff --git a/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/FileKitLinuxTest.kt b/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/FileKitLinuxTest.kt new file mode 100644 index 00000000..5bc39d6c --- /dev/null +++ b/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/FileKitLinuxTest.kt @@ -0,0 +1,117 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + +package io.github.vinceglb.filekit + +import io.github.vinceglb.filekit.exceptions.FileKitException +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class FileKitLinuxTest { + private val sandbox = FileKit.projectDir / "build/linux-test-sandbox" + + @AfterTest + fun tearDown() { + // Leave global FileKit state initialized to a known appId for any later test + FileKit.init(appId = APP_ID) + } + + @Test + fun FileKit_projectDir_isTheWorkingDirectory() { + assertEquals(expected = ".", actual = FileKit.projectDir.path) + } + + @Test + fun FileKit_filesDir_withCustomDirectory_usesItAndCreatesIt() { + val filesDir = sandbox / "files" + val cacheDir = sandbox / "cache" + FileKit.init(appId = APP_ID, filesDir = filesDir, cacheDir = cacheDir) + + assertEquals(expected = filesDir.path, actual = FileKit.filesDir.path) + assertTrue(FileKit.filesDir.exists(), "filesDir should be created on access") + assertTrue(FileKit.filesDir.isDirectory()) + } + + @Test + fun FileKit_cacheDir_withCustomDirectory_usesItAndCreatesIt() { + val filesDir = sandbox / "files" + val cacheDir = sandbox / "cache" + FileKit.init(appId = APP_ID, filesDir = filesDir, cacheDir = cacheDir) + + assertEquals(expected = cacheDir.path, actual = FileKit.cacheDir.path) + assertTrue(FileKit.cacheDir.exists(), "cacheDir should be created on access") + assertTrue(FileKit.cacheDir.isDirectory()) + } + + @Test + fun FileKit_databasesDir_isNestedUnderFilesDir() { + val filesDir = sandbox / "files" + FileKit.init(appId = APP_ID, filesDir = filesDir, cacheDir = sandbox / "cache") + + assertEquals( + expected = (filesDir / "databases").path, + actual = FileKit.databasesDir.path, + ) + } + + @Test + fun FileKit_filesDir_withoutCustomDirectory_resolvesUnderXdgDataHome() { + FileKit.init(appId = APP_ID) + + val filesDir = FileKit.filesDir.path + + assertTrue(filesDir.startsWith("/"), "Expected an absolute path but got $filesDir") + assertTrue(filesDir.endsWith("/$APP_ID"), "Expected the appId suffix but got $filesDir") + assertTrue(FileKit.filesDir.isDirectory(), "filesDir should be created on access") + } + + @Test + fun FileKit_cacheDir_withoutCustomDirectory_resolvesUnderXdgCacheHome() { + FileKit.init(appId = APP_ID) + + val cacheDir = FileKit.cacheDir.path + + assertTrue(cacheDir.startsWith("/"), "Expected an absolute path but got $cacheDir") + assertTrue(cacheDir.endsWith("/$APP_ID"), "Expected the appId suffix but got $cacheDir") + } + + @Test + fun FileKit_appId_afterInitWithoutAppId_throws() { + FileKit.init(filesDir = sandbox / "files", cacheDir = sandbox / "cache") + + // init(filesDir, cacheDir) clears the appId, so reading it must fail loudly + assertFailsWith { FileKit.appId } + } + + @Test + fun FileKit_userDirectoryOrNull_returnsAbsolutePathOrNull() { + // The API is explicitly nullable, and resolution creates the directory, which can fail on a + // locked down machine. Only assert the shape of a result that did resolve. + FileKitUserDirectory.entries.forEach { type -> + val directory = runCatching { FileKit.userDirectoryOrNull(type)?.path } + .getOrNull() ?: return@forEach + assertTrue( + directory.startsWith("/"), + "Expected an absolute path for $type but got $directory", + ) + } + } + + @Test + fun FileKit_compressImage_isNotSupported() = runTest { + assertFailsWith { + FileKit.compressImage( + bytes = ByteArray(1), + imageFormat = ImageFormat.JPEG, + quality = 80, + ) + } + } + + private companion object { + const val APP_ID = "io.github.vinceglb.filekit.linuxtest" + } +} diff --git a/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/PlatformFileLinuxTest.kt b/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/PlatformFileLinuxTest.kt index 4e7e2740..adfe4e56 100644 --- a/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/PlatformFileLinuxTest.kt +++ b/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/PlatformFileLinuxTest.kt @@ -1,12 +1,24 @@ +@file:Suppress("ktlint:standard:function-naming", "TestFunctionName") + package io.github.vinceglb.filekit +import io.github.vinceglb.filekit.mimeType.MimeType +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlin.time.ExperimentalTime +import kotlin.time.Instant class PlatformFileLinuxTest { + private val resourceDirectory = FileKit.projectDir / "src/nonWebTest/resources" + private val textFile = resourceDirectory / "hello.txt" + private val notExistingFile = resourceDirectory / "not-existing-file.pdf" + @Test - fun absolutePath_absolutePath_isReturnedUnchanged() { + fun PlatformFile_absolutePath_absolutePath_isReturnedUnchanged() { assertEquals( expected = "/tmp/filekit/hello.txt", actual = PlatformFile("/tmp/filekit/hello.txt").absolutePath(), @@ -14,7 +26,7 @@ class PlatformFileLinuxTest { } @Test - fun absolutePath_relativePathOfMissingFile_resolvesWithoutThrowing() { + fun PlatformFile_absolutePath_relativePathOfMissingFile_resolvesWithoutThrowing() { val absolutePath = PlatformFile("does-not-exist/hello.txt").absolutePath() assertTrue(absolutePath.startsWith("/"), "Expected an absolute path but got $absolutePath") @@ -22,18 +34,15 @@ class PlatformFileLinuxTest { } @Test - fun absolutePath_relativePathOfExistingFile_resolvesToSameParentAsMissingSibling() { - val existing = FileKit.projectDir / "src/nonWebTest/resources/hello.txt" - val missing = FileKit.projectDir / "src/nonWebTest/resources/not-existing-file.pdf" - - val existingParent = existing.absolutePath().substringBeforeLast('/') - val missingParent = missing.absolutePath().substringBeforeLast('/') + fun PlatformFile_absolutePath_missingSibling_resolvesToSameParentAsExistingFile() { + val existingParent = textFile.absolutePath().substringBeforeLast('/') + val missingParent = notExistingFile.absolutePath().substringBeforeLast('/') assertEquals(expected = existingParent, actual = missingParent) } @Test - fun absolutePath_emptyPath_resolvesToWorkingDirectory() { + fun PlatformFile_absolutePath_emptyPath_resolvesToWorkingDirectory() { val absolutePath = PlatformFile("").absolutePath() assertTrue(absolutePath.startsWith("/"), "Expected an absolute path but got $absolutePath") @@ -42,4 +51,189 @@ class PlatformFileLinuxTest { "Working directory should not have a trailing slash: $absolutePath", ) } + + @Test + fun PlatformFile_write_relativeDestination_doesNotThrow() = runTest { + // Regression: absolutePath() used to require the file to exist, so isSameLogicalFileAs() + // threw FileNotFoundException for any relative destination that had not been created yet. + val destination = resourceDirectory / "linux-relative-destination.txt" + + try { + destination write textFile + assertTrue(destination.exists()) + assertEquals(expected = textFile.readString(), actual = destination.readString()) + } finally { + if (destination.exists()) { + destination.delete(mustExist = false) + } + } + } + + @OptIn(ExperimentalTime::class) + @Test + fun PlatformFile_createdAt_returnsNull() { + // Linux stat() has no birth time, and st_ctim is the status change time, not creation time + assertNull(textFile.createdAt()) + assertNull(notExistingFile.createdAt()) + } + + @OptIn(ExperimentalTime::class) + @Test + fun PlatformFile_lastModified_existingFile_returnsNonEpochTime() { + val lastModified = textFile.lastModified() + + assertTrue( + lastModified > Instant.fromEpochMilliseconds(0L), + "Expected a real modification time but got $lastModified", + ) + } + + @OptIn(ExperimentalTime::class) + @Test + fun PlatformFile_lastModified_missingFile_returnsEpoch() { + assertEquals( + expected = Instant.fromEpochMilliseconds(0L), + actual = notExistingFile.lastModified(), + ) + } + + @OptIn(ExperimentalTime::class) + @Test + fun PlatformFile_lastModified_afterWrite_advances() = runTest { + val file = resourceDirectory / "linux-last-modified.txt" + + try { + file.writeString("first") + val before = file.lastModified() + + file.writeString("second") + val after = file.lastModified() + + assertTrue(after >= before, "Expected $after to be at or after $before") + } finally { + if (file.exists()) { + file.delete(mustExist = false) + } + } + } + + @Test + fun PlatformFile_bookmarkData_roundTripsToAbsolutePath() = runTest { + val bookmark = textFile.bookmarkData() + val restored = PlatformFile.fromBookmarkData(bookmark) + + assertEquals(expected = textFile.absolutePath(), actual = restored.path) + } + + @Test + fun PlatformFile_resolveBookmarkData_reportsFreshBookmark() = runTest { + val resolution = PlatformFile.resolveBookmarkData(textFile.bookmarkData()) + + assertEquals(expected = textFile.absolutePath(), actual = resolution.file.path) + assertFalse(resolution.isStale) + assertFalse(resolution.shouldRefresh) + } + + @Test + fun PlatformFile_mimeType_blankExtension_returnsNull() { + assertNull((resourceDirectory / "empty-file").mimeType()) + } + + @Test + fun PlatformFile_startAccessingSecurityScopedResource_isAlwaysGranted() { + assertTrue(textFile.startAccessingSecurityScopedResource()) + textFile.stopAccessingSecurityScopedResource() + } + + @Test + fun SystemMimeTypes_find_caseSensitiveEntry_prefersExactMatch() { + // `*.C:cs` is C++ source, `*.c` without the flag is C source + val mimeTypes = parseSharedMimeInfoGlobs( + """ + 50:text/x-c++src:*.C:cs + 50:text/x-csrc:*.c + """.trimIndent(), + ) + + assertEquals(expected = MimeType.parse("text/x-c++src"), actual = mimeTypes.find("C")) + assertEquals(expected = MimeType.parse("text/x-csrc"), actual = mimeTypes.find("c")) + } + + @Test + fun SystemMimeTypes_find_caseInsensitiveEntry_matchesAnyCase() { + val mimeTypes = parseSharedMimeInfoGlobs("50:text/plain:*.txt") + + assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("txt")) + assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("TXT")) + assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("Txt")) + } + + @Test + fun SystemMimeTypes_find_higherWeightEntry_winsOverLaterDuplicate() { + // globs2 is ordered by descending weight, so the first entry for an extension wins + val mimeTypes = parseSharedMimeInfoGlobs( + """ + 60:application/gzip:*.gz + 40:application/x-gzip:*.gz + """.trimIndent(), + ) + + assertEquals(expected = MimeType.parse("application/gzip"), actual = mimeTypes.find("gz")) + } + + @Test + fun SystemMimeTypes_find_compoundGlob_doesNotShadowSingleSuffix() { + // `*.tar.gz` must not be registered under "gz", since `extension` is only the last segment + val mimeTypes = parseSharedMimeInfoGlobs( + """ + 50:application/x-compressed-tar:*.tar.gz + 50:application/gzip:*.gz + """.trimIndent(), + ) + + assertEquals(expected = MimeType.parse("application/gzip"), actual = mimeTypes.find("gz")) + assertNull(mimeTypes.find("tar.gz")) + } + + @Test + fun SystemMimeTypes_find_malformedLines_areIgnored() { + val mimeTypes = parseSharedMimeInfoGlobs( + """ + # a comment + not-a-valid-line + 50:missing-glob + + 50:text/plain:*.txt + 50::*.bad + """.trimIndent(), + ) + + assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("txt")) + assertNull(mimeTypes.find("bad")) + } + + @Test + fun SystemMimeTypes_find_blankExtension_returnsNull() { + val mimeTypes = parseSharedMimeInfoGlobs("50:text/plain:*.txt") + + assertNull(mimeTypes.find("")) + assertNull(mimeTypes.find(" ")) + } + + @Test + fun SystemMimeTypes_parseMimeTypesDatabase_mapsEveryExtensionOnTheLine() { + val mimeTypes = parseMimeTypesDatabase( + """ + # comment + text/plain txt text + image/png png + broken-line-without-extension + """.trimIndent(), + ) + + assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("txt")) + assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("text")) + assertEquals(expected = MimeType.parse("image/png"), actual = mimeTypes.find("PNG")) + assertNull(mimeTypes.find("unknown")) + } } From 38d232f88728f0e0db96cb17d296911c2114a013 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Wed, 29 Jul 2026 18:46:10 +0200 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20Fix=20Linux=20Native=20revie?= =?UTF-8?q?w=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/core/file-utils.mdx | 11 ++- docs/core/setup.mdx | 12 +++ docs/installation.mdx | 3 + .../filekit/FileKitUserDirectories.desktop.kt | 81 +++++++++++++++ .../filekit/FileKitUserDirectories.jvm.kt | 76 +------------- .../github/vinceglb/filekit/FileKit.linux.kt | 96 +++--------------- .../vinceglb/filekit/PlatformFile.linux.kt | 99 ++++++++++++------- .../vinceglb/filekit/PlatformFileLinuxTest.kt | 71 ++++++++++--- 8 files changed, 236 insertions(+), 213 deletions(-) create mode 100644 filekit-core/src/desktopMain/kotlin/io/github/vinceglb/filekit/FileKitUserDirectories.desktop.kt diff --git a/docs/core/file-utils.mdx b/docs/core/file-utils.mdx index 71a3fc3e..0487ea5d 100644 --- a/docs/core/file-utils.mdx +++ b/docs/core/file-utils.mdx @@ -7,7 +7,7 @@ import DownloadFilesWeb from '/snippets/download-files-web.mdx' ## Standard Directories -Supported on Android, iOS, macOS, JVM targets +Supported on Android, iOS, macOS, JVM, and Kotlin/Native Linux targets FileKit provides access to standard platform-specific directories: @@ -54,13 +54,18 @@ Each platform maps these standard directories to different locations according t - Windows: `%LOCALAPPDATA%//Cache/` - **databasesDir**: Maps to a `databases` subdirectory within filesDir +**Kotlin/Native Linux (`linuxX64`, `linuxArm64`)** +- **filesDir**: Maps to `$XDG_DATA_HOME//`, or `~/.local/share//` when `XDG_DATA_HOME` is unset +- **cacheDir**: Maps to `$XDG_CACHE_HOME//`, or `~/.cache//` when `XDG_CACHE_HOME` is unset +- **databasesDir**: Maps to a `databases` subdirectory within filesDir + -On JVM and macOS platforms, you must initialize FileKit with an application ID before accessing these directories. See the [Setup guide](/core/setup) for details. +On JVM, macOS, and Kotlin/Native Linux platforms, you must initialize FileKit with an application ID before accessing these directories. See the [Setup guide](/core/setup) for details. ### Additional Directories -On desktop platforms (JVM and macOS), FileKit provides access to common user directories: +On desktop platforms (JVM, macOS, and Kotlin/Native Linux), FileKit provides access to common user directories: ```kotlin // Typed API diff --git a/docs/core/setup.mdx b/docs/core/setup.mdx index 2bc9bc31..e47d8a78 100644 --- a/docs/core/setup.mdx +++ b/docs/core/setup.mdx @@ -37,6 +37,18 @@ class MainActivity : ComponentActivity() { +### Linux Native setup + +For `linuxX64` and `linuxArm64`, initialize FileKit with your application ID before accessing the standard application directories: + +```kotlin +fun main() { + FileKit.init(appId = "my.application.id") +} +``` + +FileKit uses the XDG data and cache directories by default. You can instead pass explicit `PlatformFile` values for `filesDir` and `cacheDir`. + ### iOS, macOS, JS, WASM setup No additional setup is needed for iOS, macOS, JS, and WASM targets. FileKit Core works out of the box on these platforms. diff --git a/docs/installation.mdx b/docs/installation.mdx index 69894a79..f0272544 100644 --- a/docs/installation.mdx +++ b/docs/installation.mdx @@ -11,8 +11,11 @@ FileKit supports the following targets: - Android - iOS, macOS - JVM (Windows, macOS, Linux) +- Kotlin/Native Linux (`linuxX64`, `linuxArm64`) in FileKit Core - JS, WASM +FileKit Dialogs does not yet support Kotlin/Native Linux or Aurora OS. Those integrations are separate from the FileKit Core support described here. + ## FileKit Core FileKit Core provides the fundamental file operations with the `PlatformFile` abstraction. It allows you to work with files in a platform-agnostic way. You can create, read, write, and delete files using the `PlatformFile` API. diff --git a/filekit-core/src/desktopMain/kotlin/io/github/vinceglb/filekit/FileKitUserDirectories.desktop.kt b/filekit-core/src/desktopMain/kotlin/io/github/vinceglb/filekit/FileKitUserDirectories.desktop.kt new file mode 100644 index 00000000..fb54b01d --- /dev/null +++ b/filekit-core/src/desktopMain/kotlin/io/github/vinceglb/filekit/FileKitUserDirectories.desktop.kt @@ -0,0 +1,81 @@ +package io.github.vinceglb.filekit + +import io.github.vinceglb.filekit.utils.div +import io.github.vinceglb.filekit.utils.toPath +import kotlinx.io.files.Path + +internal fun resolveLinuxUserDirectoryPath( + type: FileKitUserDirectory, + home: String?, + envProvider: (String) -> String?, + linuxUserDirsConfigProvider: () -> String?, +): Path? { + val safeHome = home + ?.takeIf(String::isNotBlank) + ?: return null + val envValue = envProvider(type.xdgEnvKey) + ?.takeIf(String::isNotBlank) + ?.let { expandHomeVariable(it, safeHome) } + if (envValue != null) { + return envValue.toPath() + } + + val configuredValue = linuxUserDirsConfigProvider() + ?.takeIf(String::isNotBlank) + ?.let(::parseXdgUserDirsConfig) + ?.get(type.xdgEnvKey) + ?.takeIf(String::isNotBlank) + ?.let { expandHomeVariable(it, safeHome) } + if (configuredValue != null) { + return configuredValue.toPath() + } + + return safeHome.toPath() / type.linuxFallbackDirName +} + +internal 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) + } + } + +private fun expandHomeVariable(path: String, home: String): String = + path + .replace("\${HOME}", home) + .replace("\$HOME", home) + +private val FileKitUserDirectory.xdgEnvKey: String + get() = when (this) { + 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" + } + +private val FileKitUserDirectory.linuxFallbackDirName: String + get() = when (this) { + FileKitUserDirectory.Downloads -> "Downloads" + FileKitUserDirectory.Pictures -> "Pictures" + FileKitUserDirectory.Videos -> "Videos" + FileKitUserDirectory.Music -> "Music" + FileKitUserDirectory.Documents -> "Documents" + } diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/FileKitUserDirectories.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/FileKitUserDirectories.jvm.kt index e5b648b4..57bdec45 100644 --- a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/FileKitUserDirectories.jvm.kt +++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/FileKitUserDirectories.jvm.kt @@ -17,6 +17,7 @@ internal fun resolveJvmUserDirectoryPath( ): Path? = when (platform) { Platform.Linux -> resolveLinuxUserDirectoryPath( type = type, + home = envProvider("HOME"), envProvider = envProvider, linuxUserDirsConfigProvider = linuxUserDirsConfigProvider, ) @@ -59,58 +60,6 @@ internal fun resolveKnownFolderPath(type: FileKitUserDirectory): String? = ) }.getOrNull() -internal 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) - } - } - -private fun resolveLinuxUserDirectoryPath( - type: FileKitUserDirectory, - envProvider: (String) -> String?, - linuxUserDirsConfigProvider: () -> String?, -): Path? { - val home = envProvider("HOME") - ?.takeIf(String::isNotBlank) - ?: return null - val envValue = envProvider(type.xdgEnvKey) - ?.takeIf(String::isNotBlank) - ?.let { expandHomeVariable(it, home) } - if (envValue != null) { - return envValue.toPath() - } - - val configuredValue = linuxUserDirsConfigProvider() - ?.takeIf(String::isNotBlank) - ?.let(::parseXdgUserDirsConfig) - ?.get(type.xdgEnvKey) - ?.takeIf(String::isNotBlank) - ?.let { expandHomeVariable(it, home) } - if (configuredValue != null) { - return configuredValue.toPath() - } - - return (home.toPath() / type.linuxFallbackDirName) -} - private fun resolveMacUserDirectoryPath(type: FileKitUserDirectory, home: String?): Path? { val safeHome = home?.takeIf(String::isNotBlank) ?: return null return (safeHome.toPath() / type.macFallbackDirName) @@ -133,29 +82,6 @@ private fun resolveWindowsUserDirectoryPath( return (userProfile.toPath() / type.windowsFallbackDirName) } -private fun expandHomeVariable(path: String, home: String): String = - path - .replace("\${HOME}", home) - .replace("\$HOME", home) - -private val FileKitUserDirectory.xdgEnvKey: String - get() = when (this) { - 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" - } - -private val FileKitUserDirectory.linuxFallbackDirName: String - get() = when (this) { - FileKitUserDirectory.Downloads -> "Downloads" - FileKitUserDirectory.Pictures -> "Pictures" - FileKitUserDirectory.Videos -> "Videos" - FileKitUserDirectory.Music -> "Music" - FileKitUserDirectory.Documents -> "Documents" - } - private val FileKitUserDirectory.macFallbackDirName: String get() = when (this) { FileKitUserDirectory.Downloads -> "Downloads" 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 index ca13fa5e..1fb6f020 100644 --- 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 @@ -70,57 +70,16 @@ public actual val FileKit.databasesDir: PlatformFile public actual val FileKit.projectDir: PlatformFile get() = PlatformFile(".") -/** - * The XDG variable name and default folder name for each user directory, kept together so the two - * cannot drift apart. - */ -private val FileKitUserDirectory.xdgEnvKey: String - get() = when (this) { - 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" - } - -private val FileKitUserDirectory.defaultFolderName: String - get() = when (this) { - FileKitUserDirectory.Downloads -> "Downloads" - FileKitUserDirectory.Pictures -> "Pictures" - FileKitUserDirectory.Videos -> "Videos" - FileKitUserDirectory.Music -> "Music" - FileKitUserDirectory.Documents -> "Documents" - } - internal actual fun FileKit.platformUserDirectoryOrNull(type: FileKitUserDirectory): PlatformFile? { val home = homeDirectoryOrNull() ?: return null - val envKey = type.xdgEnvKey - val fallbackName = type.defaultFolderName - - // Primary: the XDG user directory environment variable - val envValue = getEnv(envKey) - ?.takeIf(String::isNotBlank) - ?.let { expandHomeVariable(it, home) } - if (envValue != null) { - val path = Path(envValue) - path.assertExists() - return PlatformFile(path) - } - - // Fallback: the xdg-user-dirs configuration file written by xdg-user-dirs-update - val configuredValue = readXdgUserDirsConfig(home)[envKey] - ?.takeIf(String::isNotBlank) - ?.let { expandHomeVariable(it, home) } - if (configuredValue != null) { - val path = Path(configuredValue) - path.assertExists() - return PlatformFile(path) - } - - // Last resort: the default English folder name inside HOME (same as JVM) - val fallbackPath = Path(home, fallbackName) - fallbackPath.assertExists() - return PlatformFile(fallbackPath) + val path = resolveLinuxUserDirectoryPath( + type = type, + home = home, + envProvider = ::getEnv, + linuxUserDirsConfigProvider = { readXdgUserDirsConfig(home) }, + ) ?: return null + path.assertExists() + return PlatformFile(path) } public actual suspend fun FileKit.saveImageToGallery( @@ -191,43 +150,12 @@ private fun Path.assertExists() { } } -private fun expandHomeVariable(path: String, home: String): String = - path - .replace("\${HOME}", home) - .replace("\$HOME", home) - -private fun readXdgUserDirsConfig(home: String): Map { +private fun readXdgUserDirsConfig(home: String): String? { val configHome = getEnv("XDG_CONFIG_HOME")?.takeIf { it.startsWith("/") } ?: "$home/.config" val configFile = Path(configHome, "user-dirs.dirs") - if (!SystemFileSystem.exists(configFile)) return emptyMap() + if (!SystemFileSystem.exists(configFile)) return null - val content = runCatching { + return runCatching { SystemFileSystem.source(configFile).buffered().use { it.readString() } - }.getOrNull() ?: return emptyMap() - - return parseXdgUserDirsConfig(content) + }.getOrNull() } - -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 index 6112a759..99e91cd9 100644 --- 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 @@ -17,6 +17,7 @@ import kotlinx.io.files.Path import kotlinx.io.files.SystemFileSystem import kotlinx.io.readString import kotlinx.serialization.Serializable +import platform.posix.fnmatch import platform.posix.getcwd import platform.posix.stat import kotlin.time.ExperimentalTime @@ -126,29 +127,49 @@ public actual fun PlatformFile.lastModified(): Instant = memScoped { } public actual fun PlatformFile.mimeType(): MimeType? = - systemMimeTypes.find(extension) + systemMimeTypes.find(name) private const val SHARED_MIME_INFO_GLOBS = "/usr/share/mime/globs2" private const val MIME_TYPES_DATABASE = "/etc/mime.types" -/** - * Extension to MIME type lookup, split by whether the source entry demanded a case sensitive match. - * - * shared-mime-info marks entries such as `*.C` (C++ source) with the `cs` flag to distinguish them - * from their case insensitive counterparts like `*.c` (C source), so the two cannot share a map. - */ +/** Filename glob rules loaded from the system MIME database. */ internal class SystemMimeTypes( - private val caseSensitive: Map, - private val caseInsensitive: Map, + private val rules: List, ) { - fun find(extension: String): MimeType? { - if (extension.isBlank()) return null - return caseSensitive[extension] ?: caseInsensitive[extension.lowercase()] + fun find(fileName: String): MimeType? { + if (fileName.isBlank()) return null + val matches = rules.filter { it.matches(fileName) } + val literalMatches = matches.filter { it.pattern.isLiteralGlob() } + val candidates = literalMatches.ifEmpty { matches } + return candidates + .maxWithOrNull( + compareBy { it.weight } + .thenBy { it.pattern.length } + .thenBy { -it.order }, + )?.mimeType } - fun isEmpty(): Boolean = caseSensitive.isEmpty() && caseInsensitive.isEmpty() + fun isEmpty(): Boolean = rules.isEmpty() +} + +internal data class MimeGlobRule( + val weight: Int, + val mimeType: MimeType, + val pattern: String, + val caseSensitive: Boolean, + val order: Int, +) { + @OptIn(ExperimentalForeignApi::class) + fun matches(fileName: String): Boolean { + val matchPattern = if (caseSensitive) pattern else pattern.lowercase() + val matchFileName = if (caseSensitive) fileName else fileName.lowercase() + return fnmatch(matchPattern, matchFileName, 0) == 0 + } } +private fun String.isLiteralGlob(): Boolean = + none { it == '*' || it == '?' || it == '[' } + /** * MIME type mapping read once from the system MIME databases. * @@ -162,7 +183,7 @@ private val systemMimeTypes: SystemMimeTypes by lazy { ?.takeIf { !it.isEmpty() } ?: readSystemFileOrNull(MIME_TYPES_DATABASE) ?.let(::parseMimeTypesDatabase) - ?: SystemMimeTypes(caseSensitive = emptyMap(), caseInsensitive = emptyMap()) + ?: SystemMimeTypes(rules = emptyList()) } /** @@ -174,8 +195,7 @@ private val systemMimeTypes: SystemMimeTypes by lazy { * See https://specifications.freedesktop.org/shared-mime-info/latest-single/ */ internal fun parseSharedMimeInfoGlobs(content: String): SystemMimeTypes { - val caseSensitive = mutableMapOf() - val caseInsensitive = mutableMapOf() + val rules = mutableListOf() content .lineSequence() @@ -184,26 +204,29 @@ internal fun parseSharedMimeInfoGlobs(content: String): SystemMimeTypes { val parts = line.split(':') if (parts.size < 3) return@forEach - val glob = parts[2].trim() - - // Only single suffix `*.ext` globs are usable here. `extension` is the segment after - // the last dot, so a compound glob such as `*.tar.gz` can never be addressed by it and - // would shadow the correct `*.gz` entry if it were registered under "gz". - if (!glob.startsWith("*.") || glob.count { it == '.' } != 1) return@forEach - - val extension = glob.removePrefix("*.") + val weight = parts[0].toIntOrNull()?.takeIf { it in 0..100 } ?: return@forEach val mimeType = parseMimeTypeOrNull(parts[1].trim()) ?: return@forEach - val flags = parts.drop(3).map { it.trim() } - - // Entries are weight ordered, so the first one wins - if (flags.contains("cs")) { - caseSensitive.getOrPut(extension) { mimeType } - } else { - caseInsensitive.getOrPut(extension.lowercase()) { mimeType } + val pattern = parts[2] + if (pattern == "__NOGLOBS__") { + rules.removeAll { it.mimeType == mimeType } + return@forEach } + + val flags = parts + .getOrNull(3) + ?.split(',') + ?.map(String::trim) + .orEmpty() + rules += MimeGlobRule( + weight = weight, + mimeType = mimeType, + pattern = pattern, + caseSensitive = "cs" in flags, + order = rules.size, + ) } - return SystemMimeTypes(caseSensitive = caseSensitive, caseInsensitive = caseInsensitive) + return SystemMimeTypes(rules) } /** @@ -212,7 +235,7 @@ internal fun parseSharedMimeInfoGlobs(content: String): SystemMimeTypes { * Lines look like `text/plain txt text`. */ internal fun parseMimeTypesDatabase(content: String): SystemMimeTypes { - val caseInsensitive = mutableMapOf() + val rules = mutableListOf() content .lineSequence() @@ -223,11 +246,17 @@ internal fun parseMimeTypesDatabase(content: String): SystemMimeTypes { val mimeType = parseMimeTypeOrNull(tokens[0]) ?: return@forEach tokens.drop(1).forEach { extension -> - caseInsensitive.getOrPut(extension.lowercase()) { mimeType } + rules += MimeGlobRule( + weight = 50, + mimeType = mimeType, + pattern = "*.$extension", + caseSensitive = false, + order = rules.size, + ) } } - return SystemMimeTypes(caseSensitive = emptyMap(), caseInsensitive = caseInsensitive) + return SystemMimeTypes(rules) } private fun parseMimeTypeOrNull(value: String): MimeType? = diff --git a/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/PlatformFileLinuxTest.kt b/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/PlatformFileLinuxTest.kt index adfe4e56..6b5c851f 100644 --- a/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/PlatformFileLinuxTest.kt +++ b/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/PlatformFileLinuxTest.kt @@ -155,17 +155,30 @@ class PlatformFileLinuxTest { """.trimIndent(), ) - assertEquals(expected = MimeType.parse("text/x-c++src"), actual = mimeTypes.find("C")) - assertEquals(expected = MimeType.parse("text/x-csrc"), actual = mimeTypes.find("c")) + assertEquals(expected = MimeType.parse("text/x-c++src"), actual = mimeTypes.find("main.C")) + assertEquals(expected = MimeType.parse("text/x-csrc"), actual = mimeTypes.find("main.c")) } @Test fun SystemMimeTypes_find_caseInsensitiveEntry_matchesAnyCase() { val mimeTypes = parseSharedMimeInfoGlobs("50:text/plain:*.txt") - assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("txt")) - assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("TXT")) - assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("Txt")) + assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("notes.txt")) + assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("notes.TXT")) + assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("notes.Txt")) + } + + @Test + fun SystemMimeTypes_find_commaSeparatedCaseSensitiveFlag_isHonored() { + val mimeTypes = parseSharedMimeInfoGlobs( + """ + 50:text/x-c++src:*.C:cs,newflag:future-field + 50:text/x-csrc:*.c + """.trimIndent(), + ) + + assertEquals(expected = MimeType.parse("text/x-c++src"), actual = mimeTypes.find("main.C")) + assertEquals(expected = MimeType.parse("text/x-csrc"), actual = mimeTypes.find("main.c")) } @Test @@ -178,12 +191,11 @@ class PlatformFileLinuxTest { """.trimIndent(), ) - assertEquals(expected = MimeType.parse("application/gzip"), actual = mimeTypes.find("gz")) + assertEquals(expected = MimeType.parse("application/gzip"), actual = mimeTypes.find("archive.gz")) } @Test - fun SystemMimeTypes_find_compoundGlob_doesNotShadowSingleSuffix() { - // `*.tar.gz` must not be registered under "gz", since `extension` is only the last segment + fun SystemMimeTypes_find_sameWeightCompoundGlob_prefersLongestPattern() { val mimeTypes = parseSharedMimeInfoGlobs( """ 50:application/x-compressed-tar:*.tar.gz @@ -191,8 +203,35 @@ class PlatformFileLinuxTest { """.trimIndent(), ) - assertEquals(expected = MimeType.parse("application/gzip"), actual = mimeTypes.find("gz")) - assertNull(mimeTypes.find("tar.gz")) + assertEquals( + expected = MimeType.parse("application/x-compressed-tar"), + actual = mimeTypes.find("archive.tar.gz"), + ) + assertEquals(expected = MimeType.parse("application/gzip"), actual = mimeTypes.find("archive.gz")) + } + + @Test + fun SystemMimeTypes_find_higherWeightWinsBeforePatternLength() { + val mimeTypes = parseSharedMimeInfoGlobs( + """ + 60:application/gzip:*.gz + 50:application/x-compressed-tar:*.tar.gz + """.trimIndent(), + ) + + assertEquals(expected = MimeType.parse("application/gzip"), actual = mimeTypes.find("archive.tar.gz")) + } + + @Test + fun SystemMimeTypes_find_literalPatternWinsBeforeWildcard() { + val mimeTypes = parseSharedMimeInfoGlobs( + """ + 10:text/x-makefile:Makefile + 100:text/plain:* + """.trimIndent(), + ) + + assertEquals(expected = MimeType.parse("text/x-makefile"), actual = mimeTypes.find("Makefile")) } @Test @@ -208,8 +247,8 @@ class PlatformFileLinuxTest { """.trimIndent(), ) - assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("txt")) - assertNull(mimeTypes.find("bad")) + assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("notes.txt")) + assertNull(mimeTypes.find("notes.bad")) } @Test @@ -231,9 +270,9 @@ class PlatformFileLinuxTest { """.trimIndent(), ) - assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("txt")) - assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("text")) - assertEquals(expected = MimeType.parse("image/png"), actual = mimeTypes.find("PNG")) - assertNull(mimeTypes.find("unknown")) + assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("notes.txt")) + assertEquals(expected = MimeType.parse("text/plain"), actual = mimeTypes.find("notes.text")) + assertEquals(expected = MimeType.parse("image/png"), actual = mimeTypes.find("image.PNG")) + assertNull(mimeTypes.find("file.unknown")) } } From 829529b6ff47ae2a250fef844ab3c76f79af0d9d Mon Sep 17 00:00:00 2001 From: vinceglb Date: Wed, 29 Jul 2026 18:50:40 +0200 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=93=9D=20Document=20Linux=20Native=20?= =?UTF-8?q?core=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cookbook/test-resources.mdx | 2 +- docs/core/bookmark-data.mdx | 2 +- docs/core/platform-file.mdx | 5 +++-- docs/core/read-file.mdx | 6 +++--- docs/core/write-file.mdx | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/cookbook/test-resources.mdx b/docs/cookbook/test-resources.mdx index 523faed3..1296a33f 100644 --- a/docs/cookbook/test-resources.mdx +++ b/docs/cookbook/test-resources.mdx @@ -84,6 +84,6 @@ val file = FileKit.projectDir / "src" / "commonTest" / "resources" / "data.json" | iOS | โœ… | | macOS | โœ… | | JVM | โœ… | +| Kotlin/Native Linux (`linuxX64`, `linuxArm64`) | โœ… | | JS | โŒ | | WASM | โŒ | - diff --git a/docs/core/bookmark-data.mdx b/docs/core/bookmark-data.mdx index 1014b799..56f9504c 100644 --- a/docs/core/bookmark-data.mdx +++ b/docs/core/bookmark-data.mdx @@ -155,7 +155,7 @@ FileKit abstracts away the details, but here's what happens on each platform: - **JVM macOS**: Uses the same versioned native bookmark format through CoreFoundation. A `PlatformFile` restored from a security-scoped bookmark retains its access capability, including for children inside a bookmarked directory. -- **JVM Linux and Windows**: Stores the file path. These platforms keep their existing bookmark representation. +- **Kotlin/Native Linux, JVM Linux, and Windows**: Stores the file path. These platforms use a path-based bookmark representation. New macOS bookmark data is wrapped in a versioned FileKit format. Existing unwrapped Kotlin/Native bookmarks and JVM path bytes remain readable. Bookmark bytes are platform-specific and must not be treated as portable between operating systems or applications. diff --git a/docs/core/platform-file.mdx b/docs/core/platform-file.mdx index a01289b7..1a71ddbe 100644 --- a/docs/core/platform-file.mdx +++ b/docs/core/platform-file.mdx @@ -3,7 +3,7 @@ title: 'PlatformFile' description: 'Cross-platform file representation for Kotlin Multiplatform' --- -Supported on Android, iOS, macOS, JVM, JS and WASM targets +Supported on Android, iOS, macOS, JVM, Kotlin/Native Linux, JS and WASM targets ## Introduction @@ -165,7 +165,8 @@ Use `mimeType()` to ask the underlying platform for the best-known media type. I - **Apple platforms**: FileKit first queries provider metadata (e.g., from the Files app or iCloud Drive) and falls back to the filename extension when needed, so even extension-less documents can resolve to a MIME type. - **Android**: The resolver consults the `ContentResolver` for `content://` URIs and then falls back to `MimeTypeMap` if necessary. -- **Desktop & Web**: The lookup is derived from the file extension. +- **JVM Desktop & Web**: The lookup is derived from the file extension or the desktop platform's MIME registry. +- **Kotlin/Native Linux**: FileKit matches the filename against the system shared MIME database, with `/etc/mime.types` as a fallback. This utility is helpful when you need to validate file types, populate upload headers, or customise previews based on content. diff --git a/docs/core/read-file.mdx b/docs/core/read-file.mdx index 66eba3d0..0243a4d8 100644 --- a/docs/core/read-file.mdx +++ b/docs/core/read-file.mdx @@ -9,7 +9,7 @@ FileKit Core provides a consistent API for reading files across all platforms. T ## Reading as ByteArray -Supported on Android, iOS, macOS, JVM, JS and WASM targets +Supported on Android, iOS, macOS, JVM, Kotlin/Native Linux, JS and WASM targets The most common way to read a file is to use the `readBytes()` suspend function, which returns the file contents as a `ByteArray`: @@ -22,7 +22,7 @@ val bytes: ByteArray = file.readBytes() ## Reading as String -Supported on Android, iOS, macOS, JVM, JS and WASM targets +Supported on Android, iOS, macOS, JVM, Kotlin/Native Linux, JS and WASM targets For text files, you can use the `readString()` suspend function, which returns the file contents as a `String`: @@ -36,7 +36,7 @@ println(text) ## Using Source -Supported on Android, iOS, macOS, JVM targets +Supported on Android, iOS, macOS, JVM and Kotlin/Native Linux targets For more advanced use cases or when working with large files, you can use the `source()` method from [kotlinx-io](https://github.com/Kotlin/kotlinx-io) to get a raw source: diff --git a/docs/core/write-file.mdx b/docs/core/write-file.mdx index 02968972..fb94e5e4 100644 --- a/docs/core/write-file.mdx +++ b/docs/core/write-file.mdx @@ -5,7 +5,7 @@ description: 'Write files with FileKit Core in Kotlin Multiplatform' import DownloadFilesWeb from '/snippets/download-files-web.mdx' -Supported on Android, iOS, macOS, JVM targets +Supported on Android, iOS, macOS, JVM and Kotlin/Native Linux targets FileKit Core provides a consistent API for writing files across all platforms. The `PlatformFile` class offers multiple methods to write data to files, from simple strings to binary data and streaming operations.