diff --git a/README.md b/README.md index 70358663..b31326d2 100644 --- a/README.md +++ b/README.md @@ -105,11 +105,11 @@ val file = FileKit.openFileSaver( ) file?.writeString(contentToSave) -// Work with files -val myFile = FileKit.filesDir / "document.pdf" +// Work with application storage on every platform, including the web. +val myFile = FileKit.filesDirectory().file(name = "document.pdf", create = true) println(myFile.name) -println(myFile.size()) -myFile.writeString("Hello, World!") +println(myFile.sizeAsync()) +myFile.writeStringAsync("Hello, World!") // Image operations val compressedBytes = FileKit.compressImage( diff --git a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/FileKit.kt b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/FileKit.kt index 40fe95c5..f3728a9e 100644 --- a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/FileKit.kt +++ b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/FileKit.kt @@ -1,3 +1,13 @@ package io.github.vinceglb.filekit public expect object FileKit + +/** + * Returns the directory for persistent application files. + * + * On Android, this corresponds to `Context.filesDir`. + * On Apple, this corresponds to `NSApplicationSupportDirectory`. + * On JVM, this corresponds to a platform-specific application data directory. + * On web targets this is the root of the browser's origin private file system (OPFS). + */ +public expect suspend fun FileKit.filesDirectory(): PlatformFile diff --git a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt index b8cd00ea..923aaff8 100644 --- a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt +++ b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFile.kt @@ -53,6 +53,13 @@ public expect fun PlatformFile.lastModified(): Instant */ public expect suspend fun PlatformFile.readBytes(): ByteArray +/** + * Writes the given bytes to this file. + * + * @param bytes The bytes to write. + */ +public expect suspend infix fun PlatformFile.write(bytes: ByteArray): Unit + /** * Reads the content of the file as a string. * @@ -60,6 +67,13 @@ public expect suspend fun PlatformFile.readBytes(): ByteArray */ public expect suspend fun PlatformFile.readString(): String +/** + * Writes the given string to this file. + * + * @param string The string to write. + */ +public expect suspend fun PlatformFile.writeString(string: String): Unit + /** * Returns the MIME type of the file. * @@ -110,6 +124,20 @@ public expect inline fun PlatformFile.list(block: (List) -> Unit) */ public expect fun PlatformFile.list(): List +/** + * Deletes this file. + * + * @param mustExist If `true`, fails if the file does not exist. Defaults to `true`. + * @param recursively If `true`, a directory is emptied before it is removed. Defaults to `false`, + * which fails on a filesystem directory that still has contents. Symbolic links (including dangling + * links) and Windows directory junctions are unlinked, never followed. Android document URI deletion + * is handled by the document provider regardless of this flag. + */ +public expect suspend fun PlatformFile.delete( + mustExist: Boolean = true, + recursively: Boolean = false, +) + /** * Starts accessing a security-scoped resource. * diff --git a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFileAsync.kt b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFileAsync.kt new file mode 100644 index 00000000..3ddfcddc --- /dev/null +++ b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFileAsync.kt @@ -0,0 +1,44 @@ +package io.github.vinceglb.filekit + +import io.github.vinceglb.filekit.mimeType.MimeType +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +/** Lists this directory's current children. */ +public expect suspend fun PlatformFile.listAsync(): List + +/** Returns a child file, creating it when [create] is true. */ +public expect suspend fun PlatformFile.file( + name: String, + create: Boolean = false, +): PlatformFile + +/** Returns a child directory, creating it when [create] is true. */ +public expect suspend fun PlatformFile.directory( + name: String, + create: Boolean = false, +): PlatformFile + +/** Returns whether this file currently exists. */ +public expect suspend fun PlatformFile.existsAsync(): Boolean + +/** Returns this file's current size in bytes. */ +public expect suspend fun PlatformFile.sizeAsync(): Long + +/** Returns this file's current MIME type, if available. */ +public expect suspend fun PlatformFile.mimeTypeAsync(): MimeType? + +/** Returns this file's current modification time. */ +@OptIn(ExperimentalTime::class) +public expect suspend fun PlatformFile.lastModifiedAsync(): Instant + +/** + * Refreshes metadata cached by this platform file. + * + * Files whose metadata is read directly from their backing filesystem do not need an update. + */ +public suspend fun PlatformFile.update() { + updatePlatformData() +} + +internal expect suspend fun PlatformFile.updatePlatformData() diff --git a/filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/OriginPrivateFileSystem.js.kt b/filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/OriginPrivateFileSystem.js.kt new file mode 100644 index 00000000..d96fdbbb --- /dev/null +++ b/filekit-core/src/jsMain/kotlin/io/github/vinceglb/filekit/OriginPrivateFileSystem.js.kt @@ -0,0 +1,10 @@ +package io.github.vinceglb.filekit + +import org.khronos.webgl.Uint8Array +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.JsAny +import kotlin.js.unsafeCast + +@OptIn(ExperimentalWasmJsInterop::class) +internal actual fun ByteArray.toWebBytes(): JsAny = + Uint8Array(toTypedArray()).unsafeCast() 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 index 5bc39d6c..0c17fe1d 100644 --- a/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/FileKitLinuxTest.kt +++ b/filekit-core/src/linuxTest/kotlin/io/github/vinceglb/filekit/FileKitLinuxTest.kt @@ -57,6 +57,13 @@ class FileKitLinuxTest { ) } + @Test + fun FileKit_asyncStorageDirectories_matchPlatformDirectories() = runTest { + FileKit.init(appId = APP_ID) + + assertEquals(FileKit.filesDir.path, FileKit.filesDirectory().path) + } + @Test fun FileKit_filesDir_withoutCustomDirectory_resolvesUnderXdgDataHome() { FileKit.init(appId = APP_ID) diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/FileKit.nonWeb.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/FileKit.nonWeb.kt index 930ec1b0..6a9e67b4 100644 --- a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/FileKit.nonWeb.kt +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/FileKit.nonWeb.kt @@ -36,6 +36,8 @@ public expect val FileKit.databasesDir: PlatformFile */ public expect val FileKit.projectDir: PlatformFile +public actual suspend fun FileKit.filesDirectory(): PlatformFile = filesDir + /** * Saves an image to the platform's gallery or photo album. * diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt index 9e11051b..113f2ccc 100644 --- a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt @@ -119,7 +119,7 @@ public actual suspend fun PlatformFile.readString(): String = * * @param bytes The bytes to write. */ -public suspend infix fun PlatformFile.write(bytes: ByteArray): Unit = +public actual suspend infix fun PlatformFile.write(bytes: ByteArray): Unit = withContext(Dispatchers.IO) { this@write .sink() @@ -163,7 +163,7 @@ public suspend infix fun PlatformFile.write(platformFile: PlatformFile): Unit = * * @param string The string to write. */ -public suspend fun PlatformFile.writeString(string: String): Unit = +public actual suspend fun PlatformFile.writeString(string: String): Unit = withContext(Dispatchers.IO) { this@writeString .sink() @@ -212,20 +212,6 @@ public expect fun PlatformFile.createDirectories(mustCreate: Boolean = false) */ public expect suspend fun PlatformFile.atomicMove(destination: PlatformFile) -/** - * Deletes this file. - * - * @param mustExist If `true`, fails if the file does not exist. Defaults to `true`. - * @param recursively If `true`, a directory is emptied before it is removed. Defaults to `false`, - * which fails on a filesystem directory that still has contents. Symbolic links (including dangling - * links) and Windows directory junctions are unlinked, never followed. Android document URI deletion - * is handled by the document provider regardless of this flag. - */ -public expect suspend fun PlatformFile.delete( - mustExist: Boolean = true, - recursively: Boolean = false, -) - /** * Empties this directory, depth first, leaving the directory itself in place. Does nothing when * this is not a directory. diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFileAsync.nonWeb.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFileAsync.nonWeb.kt new file mode 100644 index 00000000..cbec36e2 --- /dev/null +++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFileAsync.nonWeb.kt @@ -0,0 +1,46 @@ +package io.github.vinceglb.filekit + +import io.github.vinceglb.filekit.exceptions.FileKitException +import io.github.vinceglb.filekit.mimeType.MimeType +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +public actual suspend fun PlatformFile.listAsync(): List = list() + +public actual suspend fun PlatformFile.file( + name: String, + create: Boolean, +): PlatformFile { + val file = this / name + if (create && !file.exists()) { + file write ByteArray(0) + } + if (!file.exists() || !file.isRegularFile()) { + throw FileKitException("File does not exist: $file") + } + return file +} + +public actual suspend fun PlatformFile.directory( + name: String, + create: Boolean, +): PlatformFile { + val directory = this / name + if (create) { + directory.createDirectories() + } else if (!directory.exists() || !directory.isDirectory()) { + throw FileKitException("Directory does not exist: $directory") + } + return directory +} + +public actual suspend fun PlatformFile.existsAsync(): Boolean = exists() + +public actual suspend fun PlatformFile.sizeAsync(): Long = size() + +public actual suspend fun PlatformFile.mimeTypeAsync(): MimeType? = mimeType() + +@OptIn(ExperimentalTime::class) +public actual suspend fun PlatformFile.lastModifiedAsync(): Instant = lastModified() + +internal actual suspend fun PlatformFile.updatePlatformData() {} diff --git a/filekit-core/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/OriginPrivateFileSystem.wasmJs.kt b/filekit-core/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/OriginPrivateFileSystem.wasmJs.kt new file mode 100644 index 00000000..170b63d0 --- /dev/null +++ b/filekit-core/src/wasmJsMain/kotlin/io/github/vinceglb/filekit/OriginPrivateFileSystem.wasmJs.kt @@ -0,0 +1,10 @@ +package io.github.vinceglb.filekit + +import org.khronos.webgl.toInt8Array +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.JsAny +import kotlin.js.unsafeCast + +@OptIn(ExperimentalWasmJsInterop::class) +internal actual fun ByteArray.toWebBytes(): JsAny = + toInt8Array().unsafeCast() diff --git a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileKit.web.kt b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileKit.web.kt index 68de89f4..b23a6b1c 100644 --- a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileKit.web.kt +++ b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/FileKit.web.kt @@ -1,5 +1,16 @@ package io.github.vinceglb.filekit +/** + * Reads the root directory of the browser's origin private file system (OPFS). + * + * The returned [PlatformFile] retains a live OPFS directory handle. + * + * OPFS is available only in secure contexts in browsers that implement the File + * System API. + */ +public actual suspend fun FileKit.filesDirectory(): PlatformFile = + PlatformFile.fromOriginPrivateFileSystem() + /** * Downloads a file in the browser. * diff --git a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/OriginPrivateFileSystem.web.kt b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/OriginPrivateFileSystem.web.kt new file mode 100644 index 00000000..4f0393d8 --- /dev/null +++ b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/OriginPrivateFileSystem.web.kt @@ -0,0 +1,185 @@ +package io.github.vinceglb.filekit + +import io.github.vinceglb.filekit.exceptions.FileKitException +import kotlinx.coroutines.await +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.JsAny +import kotlin.js.JsName +import kotlin.js.Promise + +/** + * Reads the root directory of the browser's origin private file system (OPFS). + * + * The returned [PlatformFile] retains a live OPFS directory handle. Use + * [listAsync], [file], and + * [directory] to interact with its entries + * asynchronously. + * + * OPFS is available only in secure contexts in browsers that implement the File + * System API. + */ +@OptIn(ExperimentalWasmJsInterop::class) +internal suspend fun PlatformFile.Companion.fromOriginPrivateFileSystem(): PlatformFile { + val root = browserNavigator.storage.getDirectory().await() + return PlatformFile( + WebFile.OriginPrivateDirectory( + handle = root, + path = "", + parent = null, + ), + ) +} + +@OptIn(ExperimentalWasmJsInterop::class) +internal suspend fun WebFile.OriginPrivateDirectory.list(): List { + val iterator = handle.values() + val entries = mutableListOf() + + while (true) { + val entry = iterator.next().await() + if (entry.done) break + + val handle = entry.value + ?: throw FileKitException("Could not read origin private file system entry") + val path = path.appendOriginPrivateFileSystemPath(handle.name) + entries += PlatformFile( + when (handle.kind) { + "file" -> { + val fileHandle = handle.unsafeCast() + WebFile.OriginPrivateFile( + handle = fileHandle, + fileSnapshot = fileHandle.getFile().await(), + path = path, + parent = this, + ) + } + + "directory" -> { + WebFile.OriginPrivateDirectory( + handle = handle.unsafeCast(), + path = path, + parent = this, + ) + } + + else -> { + throw FileKitException("Unsupported origin private file system entry type: ${handle.kind}") + } + }, + ) + } + + return entries +} + +/** Returns an OPFS file in this directory, creating it when [create] is true. */ +@OptIn(ExperimentalWasmJsInterop::class) +internal suspend fun WebFile.OriginPrivateDirectory.file( + name: String, + create: Boolean = false, +): PlatformFile { + val fileHandle = handle.getFileHandle(name, FileSystemGetHandleOptions(create)).await() + return PlatformFile( + WebFile.OriginPrivateFile( + handle = fileHandle, + fileSnapshot = fileHandle.getFile().await(), + path = path.appendOriginPrivateFileSystemPath(name), + parent = this, + ), + ) +} + +/** Returns an OPFS directory in this directory, creating it when [create] is true. */ +@OptIn(ExperimentalWasmJsInterop::class) +internal suspend fun WebFile.OriginPrivateDirectory.directory( + name: String, + create: Boolean = false, +): PlatformFile = PlatformFile( + WebFile.OriginPrivateDirectory( + handle = handle.getDirectoryHandle(name, FileSystemGetHandleOptions(create)).await(), + path = path.appendOriginPrivateFileSystemPath(name), + parent = this, + ), +) + +/** Replaces the contents of this live OPFS file with [bytes]. */ +@OptIn(ExperimentalWasmJsInterop::class) +public suspend fun WebFile.OriginPrivateFile.write(bytes: ByteArray) { + val writable = handle.createWritable().await() + try { + writable.write(bytes.toWebBytes()).await() + } finally { + writable.close().await() + } +} + +internal fun String.appendOriginPrivateFileSystemPath(child: String): String = + if (isEmpty()) child else "$this/$child" + +@OptIn(ExperimentalWasmJsInterop::class) +@JsName("navigator") +private external val browserNavigator: BrowserNavigator + +@OptIn(ExperimentalWasmJsInterop::class) +private external interface BrowserNavigator : JsAny { + val storage: BrowserStorageManager +} + +@OptIn(ExperimentalWasmJsInterop::class) +private external interface BrowserStorageManager : JsAny { + fun getDirectory(): Promise +} + +@OptIn(ExperimentalWasmJsInterop::class) +internal external interface FileSystemHandle : JsAny { + val kind: String + val name: String +} + +@OptIn(ExperimentalWasmJsInterop::class) +internal external interface FileSystemFileHandle : FileSystemHandle { + fun getFile(): Promise + + fun createWritable(): Promise +} + +@OptIn(ExperimentalWasmJsInterop::class) +internal external interface FileSystemDirectoryHandle : FileSystemHandle { + fun values(): FileSystemDirectoryHandleIterator + + fun getFileHandle(name: String, options: FileSystemGetHandleOptions): Promise + + fun getDirectoryHandle(name: String, options: FileSystemGetHandleOptions): Promise + + fun removeEntry(name: String): Promise +} + +@OptIn(ExperimentalWasmJsInterop::class) +internal external interface FileSystemDirectoryHandleIterator : JsAny { + fun next(): Promise +} + +@OptIn(ExperimentalWasmJsInterop::class) +internal external interface FileSystemDirectoryHandleIteratorResult : JsAny { + val done: Boolean + val value: FileSystemHandle? +} + +@OptIn(ExperimentalWasmJsInterop::class) +internal external interface FileSystemGetHandleOptions : JsAny { + var create: Boolean +} + +@OptIn(ExperimentalWasmJsInterop::class) +internal fun FileSystemGetHandleOptions(create: Boolean): FileSystemGetHandleOptions = + js("({ create })") + +@OptIn(ExperimentalWasmJsInterop::class) +internal external interface FileSystemWritableFileStream : JsAny { + fun write(data: JsAny): Promise + + fun close(): Promise +} + +@OptIn(ExperimentalWasmJsInterop::class) +internal expect fun ByteArray.toWebBytes(): JsAny diff --git a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt index d509fd38..cc945105 100644 --- a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt +++ b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFile.web.kt @@ -3,6 +3,7 @@ package io.github.vinceglb.filekit import io.github.vinceglb.filekit.exceptions.FileKitException import io.github.vinceglb.filekit.mimeType.MimeType import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.await import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable @@ -35,29 +36,39 @@ public actual val PlatformFile.name: String get() = when (val file = webFile) { is WebFile.FileWrapper -> file.name is WebFile.DirectoryWrapper -> file.name + is WebFile.OriginPrivateFile -> file.name + is WebFile.OriginPrivateDirectory -> file.name } public actual val PlatformFile.extension: String get() = when (webFile) { is WebFile.FileWrapper -> name.substringAfterLast(".", "") is WebFile.DirectoryWrapper -> "" + is WebFile.OriginPrivateFile -> name.substringAfterLast(".", "") + is WebFile.OriginPrivateDirectory -> "" } public actual val PlatformFile.nameWithoutExtension: String get() = when (webFile) { is WebFile.FileWrapper -> name.substringBeforeLast(".", name) is WebFile.DirectoryWrapper -> name + is WebFile.OriginPrivateFile -> name.substringBeforeLast(".", name) + is WebFile.OriginPrivateDirectory -> name } public actual fun PlatformFile.size(): Long = when (val file = webFile) { is WebFile.FileWrapper -> file.size is WebFile.DirectoryWrapper -> 0 + is WebFile.OriginPrivateFile -> file.size + is WebFile.OriginPrivateDirectory -> 0 } public actual val PlatformFile.path: String get() = when (val file = webFile) { is WebFile.FileWrapper -> file.path is WebFile.DirectoryWrapper -> file.path + is WebFile.OriginPrivateFile -> file.path + is WebFile.OriginPrivateDirectory -> file.path } public actual fun PlatformFile.mimeType(): MimeType? = when (val file = webFile) { @@ -70,31 +81,58 @@ public actual fun PlatformFile.mimeType(): MimeType? = when (val file = webFile) is WebFile.DirectoryWrapper -> { null } + + is WebFile.OriginPrivateFile -> { + file.type + .takeIf { it.isNotBlank() } + ?.let { MimeType.parse(it) } + } + + is WebFile.OriginPrivateDirectory -> { + null + } } public actual fun PlatformFile.lastModified(): Instant = when (val file = webFile) { is WebFile.FileWrapper -> file.lastModified is WebFile.DirectoryWrapper -> file.lastModified + is WebFile.OriginPrivateFile -> file.lastModified + is WebFile.OriginPrivateDirectory -> WEB_DIRECTORY_LAST_MODIFIED } public actual fun PlatformFile.parent(): PlatformFile? = when (val file = webFile) { is WebFile.FileWrapper -> file.parent?.toPlatformFile() is WebFile.DirectoryWrapper -> file.parent?.toPlatformFile() + is WebFile.OriginPrivateFile -> file.parent?.toPlatformFile() + is WebFile.OriginPrivateDirectory -> file.parent?.toPlatformFile() } public actual fun PlatformFile.isRegularFile(): Boolean = - webFile is WebFile.FileWrapper + webFile is WebFile.FileWrapper || webFile is WebFile.OriginPrivateFile public actual fun PlatformFile.isDirectory(): Boolean = - webFile is WebFile.DirectoryWrapper + webFile is WebFile.DirectoryWrapper || webFile is WebFile.OriginPrivateDirectory public actual inline fun PlatformFile.list(block: (List) -> Unit) { block(list()) } public actual fun PlatformFile.list(): List = when (val file = webFile) { - is WebFile.FileWrapper -> throw FileKitException("Cannot list a regular file") - is WebFile.DirectoryWrapper -> file.children.map { it.toPlatformFile() } + is WebFile.FileWrapper -> { + throw FileKitException("Cannot list a regular file") + } + + is WebFile.DirectoryWrapper -> { + file.children.map { it.toPlatformFile() } + } + + is WebFile.OriginPrivateFile -> { + throw FileKitException("Cannot list a regular file") + } + + is WebFile.OriginPrivateDirectory -> { + throw FileKitException("Origin private file system directories must be listed asynchronously") + } } public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean = true @@ -103,36 +141,94 @@ public actual fun PlatformFile.stopAccessingSecurityScopedResource() {} @OptIn(ExperimentalWasmJsInterop::class) public actual suspend fun PlatformFile.readBytes(): ByteArray = when (val file = webFile) { - is WebFile.FileWrapper -> withContext(Dispatchers.Main) { - suspendCancellableCoroutine { continuation -> - val reader = FileReader() - reader.onload = { event -> - try { - val arrayBuffer = event - .target - ?.unsafeCast() - ?.result - ?.unsafeCast() - ?: throw FileKitException("Could not read file") - - val bytes = Uint8Array(arrayBuffer) - val byteArray = ByteArray(bytes.length) - for (i in 0 until bytes.length) { - byteArray[i] = bytes[i] - } - - continuation.resume(byteArray) - } catch (e: Exception) { - continuation.resumeWithException(e) - } - } + is WebFile.FileWrapper -> { + file.file.readBytes() + } - reader.readAsArrayBuffer(file.file) - } + is WebFile.DirectoryWrapper -> { + throw FileKitException("Cannot read bytes from a directory") } - is WebFile.DirectoryWrapper -> throw FileKitException("Cannot read bytes from a directory") + is WebFile.OriginPrivateFile -> { + file.handle + .getFile() + .await() + .readBytes() + } + + is WebFile.OriginPrivateDirectory -> { + throw FileKitException("Cannot read bytes from a directory") + } } public actual suspend fun PlatformFile.readString(): String = readBytes().decodeToString() + +@OptIn(ExperimentalWasmJsInterop::class) +private suspend fun BrowserFile.readBytes(): ByteArray = withContext(Dispatchers.Main) { + suspendCancellableCoroutine { continuation -> + val reader = FileReader() + reader.onload = { event -> + try { + val arrayBuffer = event + .target + ?.unsafeCast() + ?.result + ?.unsafeCast() + ?: throw FileKitException("Could not read file") + + val bytes = Uint8Array(arrayBuffer) + val byteArray = ByteArray(bytes.length) + for (i in 0 until bytes.length) { + byteArray[i] = bytes[i] + } + + continuation.resume(byteArray) + } catch (e: Exception) { + continuation.resumeWithException(e) + } + } + + reader.readAsArrayBuffer(this@readBytes) + } +} + +public actual suspend infix fun PlatformFile.write(bytes: ByteArray) { + when (webFile) { + is WebFile.OriginPrivateFile -> webFile.write(bytes) + else -> throw FileKitException("This file is not a writable origin private file system file") + } +} + +public actual suspend fun PlatformFile.writeString(string: String) { + write(string.encodeToByteArray()) +} + +@OptIn(ExperimentalWasmJsInterop::class) +public actual suspend fun PlatformFile.delete(mustExist: Boolean, recursively: Boolean) { + when (val file = webFile) { + is WebFile.OriginPrivateFile -> { + val parent = file.parent + ?: throw FileKitException("Cannot delete the origin private file system root") + if (mustExist) { + parent.handle.removeEntry(file.name).await() + } else { + runCatching { parent.handle.removeEntry(file.name).await() } + } + } + + is WebFile.OriginPrivateDirectory -> { + val parent = file.parent + ?: throw FileKitException("Cannot delete the origin private file system root") + if (mustExist) { + parent.handle.removeEntry(file.name).await() + } else { + runCatching { parent.handle.removeEntry(file.name).await() } + } + } + + else -> { + throw FileKitException("This file is not a writable origin private file system entry") + } + } +} diff --git a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFileAsync.web.kt b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFileAsync.web.kt new file mode 100644 index 00000000..6ef61765 --- /dev/null +++ b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFileAsync.web.kt @@ -0,0 +1,127 @@ +package io.github.vinceglb.filekit + +import io.github.vinceglb.filekit.exceptions.FileKitException +import io.github.vinceglb.filekit.mimeType.MimeType +import kotlinx.coroutines.await +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.toDouble +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +public actual suspend fun PlatformFile.listAsync(): List = when (val file = webFile) { + is WebFile.FileWrapper, + is WebFile.OriginPrivateFile, + -> throw FileKitException("Cannot list a regular file") + + is WebFile.DirectoryWrapper -> file.children.map { it.toPlatformFile() } + + is WebFile.OriginPrivateDirectory -> file.list() +} + +public actual suspend fun PlatformFile.file( + name: String, + create: Boolean, +): PlatformFile = when (webFile) { + is WebFile.OriginPrivateDirectory -> webFile.file(name, create) + else -> throw FileKitException("This file is not a writable origin private file system directory") +} + +@OptIn(ExperimentalWasmJsInterop::class) +public actual suspend fun PlatformFile.directory( + name: String, + create: Boolean, +): PlatformFile = when (webFile) { + is WebFile.OriginPrivateDirectory -> { + webFile.directory(name, create) + } + + else -> { + throw FileKitException("This file is not a writable origin private file system directory") + } +} + +@OptIn(ExperimentalWasmJsInterop::class) +public actual suspend fun PlatformFile.existsAsync(): Boolean = when (val file = webFile) { + is WebFile.FileWrapper, + is WebFile.DirectoryWrapper, + -> true + + is WebFile.OriginPrivateFile -> file.parent?.let { parent -> + runCatching { parent.handle.getFileHandle(file.name, FileSystemGetHandleOptions(false)).await() }.isSuccess + } ?: true + + is WebFile.OriginPrivateDirectory -> file.parent?.let { parent -> + runCatching { parent.handle.getDirectoryHandle(file.name, FileSystemGetHandleOptions(false)).await() }.isSuccess + } ?: true +} + +@OptIn(ExperimentalWasmJsInterop::class) +public actual suspend fun PlatformFile.sizeAsync(): Long = when (val file = webFile) { + is WebFile.FileWrapper -> { + file.size + } + + is WebFile.DirectoryWrapper, + is WebFile.OriginPrivateDirectory, + -> { + 0 + } + + is WebFile.OriginPrivateFile -> { + file.handle + .getFile() + .await() + .size + .toDouble() + .toLong() + } +} + +@OptIn(ExperimentalWasmJsInterop::class) +public actual suspend fun PlatformFile.mimeTypeAsync(): MimeType? = when (val file = webFile) { + is WebFile.FileWrapper -> { + file.type.takeIf(String::isNotBlank)?.let(MimeType::parse) + } + + is WebFile.DirectoryWrapper, + is WebFile.OriginPrivateDirectory, + -> { + null + } + + is WebFile.OriginPrivateFile -> { + file.handle + .getFile() + .await() + .type + .takeIf(String::isNotBlank) + ?.let(MimeType::parse) + } +} + +@Suppress("REDUNDANT_CALL_OF_CONVERSION_METHOD") +@OptIn(ExperimentalTime::class, ExperimentalWasmJsInterop::class) +public actual suspend fun PlatformFile.lastModifiedAsync(): Instant = when (val file = webFile) { + is WebFile.FileWrapper -> file.lastModified + + is WebFile.DirectoryWrapper, + is WebFile.OriginPrivateDirectory, + -> WEB_DIRECTORY_LAST_MODIFIED + + is WebFile.OriginPrivateFile -> Instant.fromEpochMilliseconds( + file.handle + .getFile() + .await() + .lastModified + .toDouble() + .toLong(), + ) +} + +@OptIn(ExperimentalWasmJsInterop::class) +internal actual suspend fun PlatformFile.updatePlatformData() { + when (val file = webFile) { + is WebFile.OriginPrivateFile -> file.updateSnapshot(file.handle.getFile().await()) + else -> Unit + } +} diff --git a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/WebFile.web.kt b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/WebFile.web.kt index 327b390e..8a4c07e5 100644 --- a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/WebFile.web.kt +++ b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/WebFile.web.kt @@ -7,16 +7,19 @@ import kotlin.time.Instant /** * FileKit's web backing type for [PlatformFile]. * - * It wraps real browser files and virtual directories reconstructed from - * browser directory picker results. + * It wraps browser file snapshots, live origin-private file-system entries, + * and virtual directories reconstructed from browser directory picker results. */ public sealed class WebFile { @OptIn(ExperimentalWasmJsInterop::class) public class FileWrapper( - public val file: BrowserFile, + file: BrowserFile, path: String? = file.webkitRelativePath, public val parent: DirectoryWrapper? = null, ) : WebFile() { + public var file: BrowserFile = file + internal set + public val path: String = path?.takeIf { it.isNotBlank() } ?: file.name public val name: String @@ -44,6 +47,40 @@ public sealed class WebFile { public val lastModified: Instant = WEB_DIRECTORY_LAST_MODIFIED } + + @OptIn(ExperimentalWasmJsInterop::class) + public class OriginPrivateFile internal constructor( + internal val handle: FileSystemFileHandle, + private var fileSnapshot: BrowserFile, + public val path: String, + public val parent: OriginPrivateDirectory?, + ) : WebFile() { + public val name: String + get() = fileSnapshot.name + + public val type: String + get() = fileSnapshot.type + + public val size: Long + get() = fileSnapshot.size.toDouble().toLong() + + @Suppress("REDUNDANT_CALL_OF_CONVERSION_METHOD") + public val lastModified: Instant + get() = Instant.fromEpochMilliseconds(fileSnapshot.lastModified.toDouble().toLong()) + + internal fun updateSnapshot(fileSnapshot: BrowserFile) { + this.fileSnapshot = fileSnapshot + } + } + + public class OriginPrivateDirectory internal constructor( + internal val handle: FileSystemDirectoryHandle, + public val path: String, + public val parent: OriginPrivateDirectory?, + ) : WebFile() { + public val name: String + get() = handle.name + } } internal val WEB_DIRECTORY_LAST_MODIFIED: Instant = Instant.fromEpochMilliseconds(0) diff --git a/filekit-core/src/webTest/kotlin/io/github/vinceglb/filekit/PlatformFileWebTest.kt b/filekit-core/src/webTest/kotlin/io/github/vinceglb/filekit/PlatformFileWebTest.kt index 098030ec..a6430bf8 100644 --- a/filekit-core/src/webTest/kotlin/io/github/vinceglb/filekit/PlatformFileWebTest.kt +++ b/filekit-core/src/webTest/kotlin/io/github/vinceglb/filekit/PlatformFileWebTest.kt @@ -173,6 +173,59 @@ class PlatformFileWebTest { ) } + @Test + fun testOriginPrivateFileSystemPathFromRoot() { + assertEquals( + expected = "hello.txt", + actual = "".appendOriginPrivateFileSystemPath("hello.txt"), + ) + } + + @Test + fun testOriginPrivateFileSystemPathFromDirectory() { + assertEquals( + expected = "folder/hello.txt", + actual = "folder".appendOriginPrivateFileSystemPath("hello.txt"), + ) + } + + @Test + fun testOriginPrivateFileSystemRoot() = runTest { + val root = PlatformFile.fromOriginPrivateFileSystem() + + assertTrue(root.isDirectory()) + assertFalse(root.isRegularFile()) + assertNull(root.parent()) + } + + @Test + fun testOriginPrivateFileSystemStorageDirectories() = runTest { + val filesDir = FileKit.filesDirectory() + + assertTrue(filesDir.isDirectory()) + assertEquals("", filesDir.path) + } + + @Test + fun testOriginPrivateFileUpdateRefreshesSnapshotAttributes() = runTest { + val root = FileKit.filesDirectory() + val name = "filekit-attributes-test.txt" + val file = root.file(name, create = true) + + try { + file.writeString("Hello") + + assertEquals(0L, file.size()) + + file.update() + + assertEquals(5L, file.size()) + assertTrue(file.lastModified() > Instant.fromEpochMilliseconds(0)) + } finally { + file.delete(mustExist = false) + } + } + private fun PlatformFile.webFileWrapper(): WebFile.FileWrapper = assertIs(webFile) }