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/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/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/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/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/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/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. 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/build.gradle.kts b/filekit-core/build.gradle.kts index f082be6d..65b685fc 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) + linuxMain.get().dependsOn(desktopMain) jvmMain.get().dependsOn(jvmAndNativeMain) nativeMain.get().dependsOn(jvmAndNativeMain) 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 new file mode 100644 index 00000000..1fb6f020 --- /dev/null +++ b/filekit-core/src/linuxMain/kotlin/io/github/vinceglb/filekit/FileKit.linux.kt @@ -0,0 +1,161 @@ +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 + 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 + ?: (xdgBaseDirectory(envKey = "XDG_DATA_HOME", homeRelativeFallback = ".local/share") / FileKit.appId) + folder.assertExists() + return PlatformFile(folder) + } + +public actual val FileKit.cacheDir: PlatformFile + get() { + val folder = FileKit.customCacheDir + ?: (xdgBaseDirectory(envKey = "XDG_CACHE_HOME", homeRelativeFallback = ".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(".") + +internal actual fun FileKit.platformUserDirectoryOrNull(type: FileKitUserDirectory): PlatformFile? { + val home = homeDirectoryOrNull() ?: return null + 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( + 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 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 = 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() { + if (!SystemFileSystem.exists(this)) { + SystemFileSystem.createDirectories(this) + } +} + +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 null + + return runCatching { + SystemFileSystem.source(configFile).buffered().use { it.readString() } + }.getOrNull() +} 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..99e91cd9 --- /dev/null +++ b/filekit-core/src/linuxMain/kotlin/io/github/vinceglb/filekit/PlatformFile.linux.kt @@ -0,0 +1,297 @@ +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.fnmatch +import platform.posix.getcwd +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 + +@PublishedApi +internal actual fun PlatformFile.withPath(path: Path): PlatformFile = PlatformFile(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 + + // 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 = + withScopedAccess { + val directoryFiles = SystemFileSystem + .list(toKotlinxIoPath()) + .map { PlatformFile(it) } + block(directoryFiles) + } + +public actual fun PlatformFile.list(): List = + withScopedAccess { + SystemFileSystem + .list(toKotlinxIoPath()) + .map { PlatformFile(it) } + } + +/** + * 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(ExperimentalTime::class) +public actual fun PlatformFile.createdAt(): Instant? = null + +@OptIn(ExperimentalForeignApi::class, ExperimentalTime::class) +public actual fun PlatformFile.lastModified(): Instant = memScoped { + val statBuf = alloc() + 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? = + systemMimeTypes.find(name) + +private const val SHARED_MIME_INFO_GLOBS = "/usr/share/mime/globs2" +private const val MIME_TYPES_DATABASE = "/etc/mime.types" + +/** Filename glob rules loaded from the system MIME database. */ +internal class SystemMimeTypes( + private val rules: List, +) { + 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 = 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. + * + * 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 systemMimeTypes: SystemMimeTypes by lazy { + readSystemFileOrNull(SHARED_MIME_INFO_GLOBS) + ?.let(::parseSharedMimeInfoGlobs) + ?.takeIf { !it.isEmpty() } + ?: readSystemFileOrNull(MIME_TYPES_DATABASE) + ?.let(::parseMimeTypesDatabase) + ?: SystemMimeTypes(rules = emptyList()) +} + +/** + * Parses the freedesktop.org shared-mime-info database. + * + * `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/ + */ +internal fun parseSharedMimeInfoGlobs(content: String): SystemMimeTypes { + val rules = mutableListOf() + + content + .lineSequence() + .filterNot { it.isBlank() || it.startsWith("#") } + .forEach { line -> + val parts = line.split(':') + if (parts.size < 3) return@forEach + + val weight = parts[0].toIntOrNull()?.takeIf { it in 0..100 } ?: return@forEach + val mimeType = parseMimeTypeOrNull(parts[1].trim()) ?: return@forEach + 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(rules) +} + +/** + * Parses an Apache style `mime.types` database, which has no case sensitive entries. + * + * Lines look like `text/plain txt text`. + */ +internal fun parseMimeTypesDatabase(content: String): SystemMimeTypes { + val rules = mutableListOf() + + 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 -> + rules += MimeGlobRule( + weight = 50, + mimeType = mimeType, + pattern = "*.$extension", + caseSensitive = false, + order = rules.size, + ) + } + } + + return SystemMimeTypes(rules) +} + +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 + +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 = resolveBookmarkData(bookmarkData).file + +public actual fun PlatformFile.Companion.resolveBookmarkData( + bookmarkData: BookmarkData, +): BookmarkResolution { + val restoredPath = bookmarkData.bytes.decodeToString() + 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 new file mode 100644 index 00000000..6b5c851f --- /dev/null +++ b/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/PlatformFileLinuxTest.kt @@ -0,0 +1,278 @@ +@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 PlatformFile_absolutePath_absolutePath_isReturnedUnchanged() { + assertEquals( + expected = "/tmp/filekit/hello.txt", + actual = PlatformFile("/tmp/filekit/hello.txt").absolutePath(), + ) + } + + @Test + fun PlatformFile_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 PlatformFile_absolutePath_missingSibling_resolvesToSameParentAsExistingFile() { + val existingParent = textFile.absolutePath().substringBeforeLast('/') + val missingParent = notExistingFile.absolutePath().substringBeforeLast('/') + + assertEquals(expected = existingParent, actual = missingParent) + } + + @Test + fun PlatformFile_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", + ) + } + + @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("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("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 + 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("archive.gz")) + } + + @Test + fun SystemMimeTypes_find_sameWeightCompoundGlob_prefersLongestPattern() { + val mimeTypes = parseSharedMimeInfoGlobs( + """ + 50:application/x-compressed-tar:*.tar.gz + 50:application/gzip:*.gz + """.trimIndent(), + ) + + 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 + 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("notes.txt")) + assertNull(mimeTypes.find("notes.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("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")) + } +}