diff --git a/README.md b/README.md index b1ec852..af98acf 100644 --- a/README.md +++ b/README.md @@ -364,17 +364,22 @@ Button( * `FilePickerFileType.Document` - Allows you to pick documents only * `FilePickerFileType.Text` - Allows you to pick text files only * `FilePickerFileType.Pdf` - Allows you to pick PDF files only -* `FilePickerFileType.Presentation` - Allows you to pick presentation files only -* `FilePickerFileType.Spreadsheet` - Allows you to pick spreadsheet files only -* `FilePickerFileType.Word` - Allows you to pick compressed word only * `FilePickerFileType.All` - Allows you to pick all types of files * `FilePickerFileType.Folder` - Allows you to pick folders -You can also specify the file types you want to pick by using the `FilePickerFileType.Custom` type: +You can filter files by custom mime types using `FilePickerFileType.Custom`. ```kotlin val type = FilePickerFileType.Custom( - "text/plain" + listOf("text/plain") +) +``` + +You can also filter files by custom extensions using `FilePickerFileType.Extension`. + +```kotlin +val type = FilePickerFileType.Extension( + listOf("txt") ) ``` diff --git a/calf-file-picker/build.gradle.kts b/calf-file-picker/build.gradle.kts index 522d62d..cdceaca 100644 --- a/calf-file-picker/build.gradle.kts +++ b/calf-file-picker/build.gradle.kts @@ -23,4 +23,8 @@ kotlin { sourceSets.androidMain.dependencies { implementation(libs.activity.compose) } + + sourceSets.desktopMain.dependencies { + implementation(libs.jna) + } } diff --git a/calf-file-picker/src/androidMain/kotlin/com/mohamedrejeb/calf/picker/FilePickerLauncher.android.kt b/calf-file-picker/src/androidMain/kotlin/com/mohamedrejeb/calf/picker/FilePickerLauncher.android.kt index 9a1b6e0..bc343de 100644 --- a/calf-file-picker/src/androidMain/kotlin/com/mohamedrejeb/calf/picker/FilePickerLauncher.android.kt +++ b/calf-file-picker/src/androidMain/kotlin/com/mohamedrejeb/calf/picker/FilePickerLauncher.android.kt @@ -1,5 +1,6 @@ package com.mohamedrejeb.calf.picker +import android.webkit.MimeTypeMap import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts @@ -60,7 +61,7 @@ private fun pickSingleVisualMedia( type: FilePickerFileType, onResult: (List) -> Unit, ): FilePickerLauncher { - val singlePhotoPickerLauncher = + val mediaPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia(), onResult = { uri -> @@ -73,7 +74,7 @@ private fun pickSingleVisualMedia( type = type, selectionMode = FilePickerSelectionMode.Single, onLaunch = { - singlePhotoPickerLauncher.launch( + mediaPickerLauncher.launch( type.toPickVisualMediaRequest(), ) }, @@ -86,7 +87,7 @@ fun pickMultipleVisualMedia( type: FilePickerFileType, onResult: (List) -> Unit, ): FilePickerLauncher { - val singlePhotoPickerLauncher = + val mediaPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia(), onResult = { uriList -> @@ -103,7 +104,7 @@ fun pickMultipleVisualMedia( type = type, selectionMode = FilePickerSelectionMode.Multiple, onLaunch = { - singlePhotoPickerLauncher.launch( + mediaPickerLauncher.launch( type.toPickVisualMediaRequest(), ) }, @@ -116,7 +117,7 @@ private fun pickSingleFile( type: FilePickerFileType, onResult: (List) -> Unit, ): FilePickerLauncher { - val singlePhotoPickerLauncher = + val filePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocument(), onResult = { uri -> @@ -129,8 +130,13 @@ private fun pickSingleFile( type = type, selectionMode = FilePickerSelectionMode.Single, onLaunch = { - singlePhotoPickerLauncher.launch( - type.value.toList().toTypedArray(), + val mimeTypeMap = MimeTypeMap.getSingleton() + + filePickerLauncher.launch( + if (type is FilePickerFileType.Extension) + type.value.mapNotNull { mimeTypeMap.getMimeTypeFromExtension(it) }.toTypedArray() + else + type.value.toList().toTypedArray() ) }, ) @@ -142,7 +148,7 @@ private fun pickMultipleFiles( type: FilePickerFileType, onResult: (List) -> Unit, ): FilePickerLauncher { - val singlePhotoPickerLauncher = + val filePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenMultipleDocuments(), onResult = { uriList -> @@ -159,8 +165,13 @@ private fun pickMultipleFiles( type = type, selectionMode = FilePickerSelectionMode.Multiple, onLaunch = { - singlePhotoPickerLauncher.launch( - type.value.toList().toTypedArray(), + val mimeTypeMap = MimeTypeMap.getSingleton() + + filePickerLauncher.launch( + if (type is FilePickerFileType.Extension) + type.value.mapNotNull { mimeTypeMap.getMimeTypeFromExtension(it) }.toTypedArray() + else + type.value.toList().toTypedArray() ) }, ) diff --git a/calf-file-picker/src/commonMain/kotlin/com.mohamedrejeb.calf/picker/FilePickerLauncher.kt b/calf-file-picker/src/commonMain/kotlin/com.mohamedrejeb.calf/picker/FilePickerLauncher.kt index 1f30eb9..d922d8a 100644 --- a/calf-file-picker/src/commonMain/kotlin/com.mohamedrejeb.calf/picker/FilePickerLauncher.kt +++ b/calf-file-picker/src/commonMain/kotlin/com.mohamedrejeb.calf/picker/FilePickerLauncher.kt @@ -33,7 +33,7 @@ sealed class FilePickerFileType(vararg val value: String) { ) data object Pdf: FilePickerFileType(PdfContentType) data object Text: FilePickerFileType(TextContentType) - data object Folder: FilePickerFileType("folder") + data object Folder: FilePickerFileType(FolderContentType) data object All: FilePickerFileType(AllContentType) /** @@ -43,6 +43,13 @@ sealed class FilePickerFileType(vararg val value: String) { */ data class Custom(val contentType: List): FilePickerFileType(*contentType.toTypedArray()) + /** + * Custom file extensions + * + * @param extensions List of extensions + */ + data class Extension(val extensions: List): FilePickerFileType(*extensions.toTypedArray()) + companion object { const val FolderContentType = "folder" const val AudioContentType = "audio/*" diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/FilePickerLauncher.desktop.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/FilePickerLauncher.desktop.kt index 707cac8..909f16b 100644 --- a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/FilePickerLauncher.desktop.kt +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/FilePickerLauncher.desktop.kt @@ -1,17 +1,13 @@ package com.mohamedrejeb.calf.picker import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.window.AwtWindow +import androidx.compose.runtime.rememberCoroutineScope import com.mohamedrejeb.calf.io.KmpFile -import java.awt.FileDialog -import java.awt.Frame +import com.mohamedrejeb.calf.picker.platform.PlatformFilePicker +import jodd.net.MimeTypes +import kotlinx.coroutines.launch import java.io.File -import java.net.URLConnection @Composable actual fun rememberFilePickerLauncher( @@ -19,72 +15,40 @@ actual fun rememberFilePickerLauncher( selectionMode: FilePickerSelectionMode, onResult: (List) -> Unit, ): FilePickerLauncher { - var fileDialogVisible by rememberSaveable { mutableStateOf(false) } - - if (fileDialogVisible) { - AwtWindow( - create = { - val frame: Frame? = null - val fileDialog = - object : FileDialog( - frame, - "Select ${if (type == FilePickerFileType.Folder) "Folder" else "File"}", - if (type == FilePickerFileType.Folder) SAVE else LOAD, - ) { - override fun setVisible(value: Boolean) { - super.setVisible(value) - if (value) { - onResult(files.orEmpty().map { KmpFile(it) }) - fileDialogVisible = false - } - } - } - - fileDialog.isMultipleMode = selectionMode == FilePickerSelectionMode.Multiple - - val mimeType = - when (type) { - FilePickerFileType.Folder -> listOf("folder") - FilePickerFileType.All -> emptyList() - else -> - type.value - .map { - it - .removeSuffix("/*") - .removeSuffix("/") - .removeSuffix("*") - } - .filter { - it.isNotEmpty() - } - } - fileDialog.setFilenameFilter { file, name -> - if (mimeType.isEmpty()) { - true - } else if (mimeType.first().contains("folder", true)) { - file.isDirectory - } else { - val contentType = URLConnection.guessContentTypeFromName(name) ?: "" - mimeType.any { - contentType.startsWith(it, true) - } - } - } - - fileDialog - }, - dispose = { - it.dispose() - }, - ) - } + val scope = rememberCoroutineScope() return remember { FilePickerLauncher( type = type, selectionMode = selectionMode, onLaunch = { - fileDialogVisible = true + scope.launch { + if (type == FilePickerFileType.Folder) + PlatformFilePicker.current.launchDirectoryPicker( + initialDirectory = null, + title = "Select a folder", + parentWindow = null, + onResult = { file -> + onResult( + if (file == null) + emptyList() + else + listOf(KmpFile(file)) + ) + } + ) + else + PlatformFilePicker.current.launchFilePicker( + initialDirectory = null, + type = type, + selectionMode = selectionMode, + title = "Select a file", + parentWindow = null, + onResult = { files -> + onResult(files.map { KmpFile(it) }) + } + ) + } }, ) } @@ -102,3 +66,9 @@ actual class FilePickerLauncher actual constructor( val File.extension: String get() = name.substringAfterLast(".") + +fun main() { + MimeTypes.findExtensionsByMimeTypes("video/*", true).also { + println(it) + } +} \ No newline at end of file diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/PlatformFilePicker.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/PlatformFilePicker.kt new file mode 100644 index 0000000..9807f90 --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/PlatformFilePicker.kt @@ -0,0 +1,48 @@ +package com.mohamedrejeb.calf.picker.platform + +import com.mohamedrejeb.calf.picker.FilePickerFileType +import com.mohamedrejeb.calf.picker.FilePickerSelectionMode +import com.mohamedrejeb.calf.picker.platform.awt.AwtFilePicker +import com.mohamedrejeb.calf.picker.platform.mac.MacOSFilePicker +import com.mohamedrejeb.calf.picker.platform.util.Platform +import com.mohamedrejeb.calf.picker.platform.util.PlatformUtil +import com.mohamedrejeb.calf.picker.platform.windows.WindowsFilePicker +import java.awt.Window +import java.io.File + +interface PlatformFilePicker { + + suspend fun launchFilePicker( + initialDirectory: String?, + type: FilePickerFileType, + selectionMode: FilePickerSelectionMode, + title: String?, + parentWindow: Window?, + onResult: (List) -> Unit, + ) + + suspend fun launchDirectoryPicker( + initialDirectory: String?, + title: String?, + parentWindow: Window?, + onResult: (File?) -> Unit, + ) + + companion object { + val current: PlatformFilePicker by lazy { createPlatformFilePicker() } + + private fun createPlatformFilePicker(): PlatformFilePicker { + return when (PlatformUtil.current) { + Platform.Windows -> + WindowsFilePicker() + + Platform.MacOS -> + MacOSFilePicker() + + Platform.Linux -> + AwtFilePicker() + } + } + } + +} \ No newline at end of file diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/awt/AwtFilePicker.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/awt/AwtFilePicker.kt new file mode 100644 index 0000000..427a6be --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/awt/AwtFilePicker.kt @@ -0,0 +1,177 @@ +package com.mohamedrejeb.calf.picker.platform.awt + +import com.mohamedrejeb.calf.picker.FilePickerFileType +import com.mohamedrejeb.calf.picker.FilePickerSelectionMode +import com.mohamedrejeb.calf.picker.platform.PlatformFilePicker +import kotlinx.coroutines.suspendCancellableCoroutine +import java.awt.Dialog +import java.awt.FileDialog +import java.awt.Frame +import java.awt.Window +import java.io.File +import java.io.FilenameFilter +import java.net.URLConnection +import kotlin.coroutines.resume + +internal class AwtFilePicker: PlatformFilePicker { + override suspend fun launchFilePicker( + initialDirectory: String?, + type: FilePickerFileType, + selectionMode: FilePickerSelectionMode, + title: String?, + parentWindow: Window?, + onResult: (List) -> Unit, + ) = callAwtFilePicker( + title = title, + initialDirectory = initialDirectory, + type = type, + selectionMode = selectionMode, + parentWindow = parentWindow, + onResult = onResult, + ) + + override suspend fun launchDirectoryPicker( + initialDirectory: String?, + title: String?, + parentWindow: Window?, + onResult: (File?) -> Unit, + ) = callAwtDirectoryPicker( + title = title, + initialDirectory = initialDirectory, + parentWindow = parentWindow, + onResult = onResult, + ) + + private suspend fun callAwtFilePicker( + title: String?, + initialDirectory: String?, + type: FilePickerFileType, + selectionMode: FilePickerSelectionMode, + parentWindow: Window?, + onResult: (List) -> Unit, + ) = suspendCancellableCoroutine { continuation -> + var dialog: FileDialog? = null + + fun handleResult(value: Boolean, files: Array?) { + if (value) { + val result = files?.toList().orEmpty() + onResult(result) + continuation.resume(Unit) + dialog?.dispose() + } + } + + // Handle parentWindow: Dialog, Frame, or null + dialog = when (parentWindow) { + is Dialog -> object : FileDialog(parentWindow, title, LOAD) { + override fun setVisible(value: Boolean) { + super.setVisible(value) + handleResult(value, files) + } + } + + else -> object : FileDialog(parentWindow as? Frame, title, LOAD) { + override fun setVisible(value: Boolean) { + super.setVisible(value) + handleResult(value, files) + } + } + } + + // Set multiple mode + dialog.isMultipleMode = selectionMode == FilePickerSelectionMode.Multiple + + // Set mime types / extensions + val mimeType = + when (type) { + is FilePickerFileType.All, is FilePickerFileType.Extension -> emptyList() + else -> + type.value + .map { + it + .removeSuffix("/*") + .removeSuffix("/") + .removeSuffix("*") + } + .filter { + it.isNotEmpty() + } + } + + dialog.filenameFilter = FilenameFilter { _, name -> + when (type) { + is FilePickerFileType.All -> + true + + is FilePickerFileType.Extension -> + type.extensions.any { name.endsWith(it) } + + else -> { + val contentType = URLConnection.guessContentTypeFromName(name) ?: "" + mimeType.any { contentType.startsWith(it, true) } + } + } + } + + // Set initial directory + dialog.directory = initialDirectory + + // Show the dialog + dialog.isVisible = true + + // Dispose the dialog when the continuation is cancelled + continuation.invokeOnCancellation { dialog.dispose() } + } + + private suspend fun callAwtDirectoryPicker( + title: String?, + initialDirectory: String?, + parentWindow: Window?, + onResult: (File?) -> Unit, + ) = suspendCancellableCoroutine { continuation -> + var dialog: FileDialog? = null + + fun handleResult(value: Boolean, files: Array?) { + if (value) { + val result = files?.firstOrNull() + onResult(result) + continuation.resume(Unit) + dialog?.dispose() + } + } + + // Handle parentWindow: Dialog, Frame, or null + dialog = when (parentWindow) { + is Dialog -> object : FileDialog(parentWindow, title, SAVE) { + override fun setVisible(value: Boolean) { + super.setVisible(value) + handleResult(value, files) + } + } + + else -> object : FileDialog(parentWindow as? Frame, title, SAVE) { + override fun setVisible(value: Boolean) { + super.setVisible(value) + handleResult(value, files) + } + } + } + + // Set multiple mode + dialog.isMultipleMode = false + + // Set mime types + dialog.filenameFilter = FilenameFilter { file, _ -> + file.isDirectory + } + + // Set initial directory + dialog.directory = initialDirectory + + // Show the dialog + dialog.isVisible = true + + // Dispose the dialog when the continuation is cancelled + continuation.invokeOnCancellation { dialog.dispose() } + } +} diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/mac/MacOSFilePicker.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/mac/MacOSFilePicker.kt new file mode 100644 index 0000000..90e539d --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/mac/MacOSFilePicker.kt @@ -0,0 +1,175 @@ +package com.mohamedrejeb.calf.picker.platform.mac + +import com.mohamedrejeb.calf.picker.FilePickerFileType +import com.mohamedrejeb.calf.picker.FilePickerSelectionMode +import com.mohamedrejeb.calf.picker.platform.PlatformFilePicker +import io.github.vinceglb.filekit.core.platform.mac.foundation.Foundation +import io.github.vinceglb.filekit.core.platform.mac.foundation.ID +import jodd.net.MimeTypes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.awt.Window +import java.io.File + +internal class MacOSFilePicker: PlatformFilePicker { + override suspend fun launchFilePicker( + initialDirectory: String?, + type: FilePickerFileType, + selectionMode: FilePickerSelectionMode, + title: String?, + parentWindow: Window?, + onResult: (List) -> Unit, + ) = + if (selectionMode == FilePickerSelectionMode.Single) + callNativeMacOSPicker( + mode = MacOSFilePickerMode.SingleFile, + initialDirectory = initialDirectory, + type = type, + title = title, + onResult = { + onResult(listOfNotNull(it)) + }, + ) + else + callNativeMacOSPicker( + mode = MacOSFilePickerMode.MultipleFiles, + initialDirectory = initialDirectory, + type = type, + title = title, + onResult = { + onResult(it.orEmpty()) + }, + ) + + override suspend fun launchDirectoryPicker( + initialDirectory: String?, + title: String?, + parentWindow: Window?, + onResult: (File?) -> Unit, + ) { + return callNativeMacOSPicker( + mode = MacOSFilePickerMode.Directories, + initialDirectory = initialDirectory, + type = FilePickerFileType.Folder, + title = title, + onResult = onResult, + ) + } + + private suspend fun callNativeMacOSPicker( + mode: MacOSFilePickerMode, + initialDirectory: String?, + type: FilePickerFileType, + title: String?, + onResult: (T?) -> Unit, + ) = withContext(Dispatchers.Default) { + val pool = Foundation.NSAutoreleasePool() + try { + var response: T? = null + + Foundation.executeOnMainThread( + withAutoreleasePool = false, + waitUntilDone = true, + ) { + // Create the file picker + val openPanel = Foundation.invoke("NSOpenPanel", "new") + + // Setup single, multiple selection or directory mode + mode.setupPickerMode(openPanel) + + // Set the title + title?.let { + Foundation.invoke(openPanel, "setMessage:", Foundation.nsString(it)) + } + + // Set initial directory + initialDirectory?.let { + Foundation.invoke(openPanel, "setDirectoryURL:", Foundation.nsURL(it)) + } + + // Set file extensions + if (type !is FilePickerFileType.Folder) { + val extensions = + if (type is FilePickerFileType.Extension) + type.extensions + else + type.value + .map { + MimeTypes.findExtensionsByMimeTypes(it, it.contains('*')) + } + .flatten() + .distinct() + val items = extensions.map { Foundation.nsString(it) } + val nsData = Foundation.invokeVarArg("NSArray", "arrayWithObjects:", *items.toTypedArray()) + Foundation.invoke(openPanel, "setAllowedFileTypes:", nsData) + } + + // Open the file picker + val result = Foundation.invoke(openPanel, "runModal") + + // Get the path(s) from the file picker if the user validated the selection + if (result.toInt() == 1) { + response = mode.getResult(openPanel) + } + } + + onResult(response) + } finally { + pool.drain() + } + } + + private companion object { + fun singlePath(openPanel: ID): File? { + val url = Foundation.invoke(openPanel, "URL") + val nsPath = Foundation.invoke(url, "path") + val path = Foundation.toStringViaUTF8(nsPath) + return path?.let { File(it) } + } + + fun multiplePaths(openPanel: ID): List? { + val urls = Foundation.invoke(openPanel, "URLs") + val urlCount = Foundation.invoke(urls, "count").toInt() + + return (0 until urlCount).mapNotNull { index -> + val url = Foundation.invoke(urls, "objectAtIndex:", index) + val nsPath = Foundation.invoke(url, "path") + val path = Foundation.toStringViaUTF8(nsPath) + path?.let { File(it) } + }.ifEmpty { null } + } + } + + private sealed class MacOSFilePickerMode { + abstract fun setupPickerMode(openPanel: ID) + abstract fun getResult(openPanel: ID): T? + + data object SingleFile : MacOSFilePickerMode() { + override fun setupPickerMode(openPanel: ID) { + Foundation.invoke(openPanel, "setCanChooseFiles:", true) + Foundation.invoke(openPanel, "setCanChooseDirectories:", false) + } + + override fun getResult(openPanel: ID): File? = singlePath(openPanel) + } + + data object MultipleFiles : MacOSFilePickerMode>() { + override fun setupPickerMode(openPanel: ID) { + Foundation.invoke(openPanel, "setCanChooseFiles:", true) + Foundation.invoke(openPanel, "setCanChooseDirectories:", false) + Foundation.invoke(openPanel, "setAllowsMultipleSelection:", true) + } + + override fun getResult(openPanel: ID): List? = multiplePaths(openPanel) + } + + data object Directories : MacOSFilePickerMode() { + override fun setupPickerMode(openPanel: ID) { + Foundation.invoke(openPanel, "setCanChooseFiles:", false) + Foundation.invoke(openPanel, "setCanChooseDirectories:", true) + } + + override fun getResult(openPanel: ID): File? = singlePath(openPanel) + } + } +} diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/mac/foundation/Foundation.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/mac/foundation/Foundation.kt new file mode 100644 index 0000000..b16efb6 --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/mac/foundation/Foundation.kt @@ -0,0 +1,590 @@ +package io.github.vinceglb.filekit.core.platform.mac.foundation + +import com.sun.jna.Callback +import com.sun.jna.Function +import com.sun.jna.Library +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.PointerType +import com.sun.jna.ptr.PointerByReference +import org.jetbrains.annotations.NonNls +import java.io.File +import java.lang.reflect.Proxy +import java.nio.CharBuffer +import java.nio.charset.StandardCharsets +import java.util.Arrays +import java.util.Collections +import java.util.UUID + +/** + * see [Documentation](http://developer.apple.com/documentation/Cocoa/Reference/ObjCRuntimeRef/Reference/reference.html) + */ +@NonNls +internal object Foundation { + private val myFoundationLibrary: FoundationLibrary = Native.load( + "Foundation", + FoundationLibrary::class.java, Collections.singletonMap("jna.encoding", "UTF8") + ) + + private val myObjcMsgSend: Function by lazy { + val nativeLibrary = (Proxy.getInvocationHandler(myFoundationLibrary) as Library.Handler).nativeLibrary + nativeLibrary.getFunction("objc_msgSend") + } + + /** + * Get the ID of the NSClass with className + */ + fun getObjcClass(className: String?): ID? { + return myFoundationLibrary.objc_getClass(className) + } + + fun getProtocol(name: String?): ID? { + return myFoundationLibrary.objc_getProtocol(name) + } + + fun createSelector(s: String?): Pointer? { + return myFoundationLibrary.sel_registerName(s) + } + + private fun prepInvoke(id: ID?, selector: Pointer?, args: Array): Array { + val invokArgs = arrayOfNulls(args.size + 2) + invokArgs[0] = id + invokArgs[1] = selector + System.arraycopy(args, 0, invokArgs, 2, args.size) + return invokArgs + } + + fun invoke(id: ID?, selector: Pointer?, vararg args: Any?): ID { + // objc_msgSend is called with the calling convention of the target method + // on x86_64 this does not make a difference, but arm64 uses a different calling convention for varargs + // it is therefore important to not call objc_msgSend as a vararg function + return ID(myObjcMsgSend.invokeLong(prepInvoke(id, selector, args))) + } + + /** + * Invokes the given vararg selector. + * Expects `NSArray arrayWithObjects:(id), ...` like signature, i.e. exactly one fixed argument, followed by varargs. + */ + fun invokeVarArg(id: ID?, selector: Pointer?, vararg args: Any?): ID? { + // c functions and objc methods have at least 1 fixed argument, we therefore need to separate out the first argument + return myFoundationLibrary.objc_msgSend( + id, selector, + args[0], *Arrays.copyOfRange(args, 1, args.size) + ) + } + + fun invoke(cls: String?, selector: String?, vararg args: Any?): ID { + return invoke(getObjcClass(cls), createSelector(selector), *args) + } + + fun invokeVarArg(cls: String?, selector: String?, vararg args: Any?): ID? { + return invokeVarArg(getObjcClass(cls), createSelector(selector), *args) + } + + fun safeInvoke(stringCls: String?, stringSelector: String?, vararg args: Any): ID { + val cls = getObjcClass(stringCls) + val selector = createSelector(stringSelector) + if (!invoke(cls, "respondsToSelector:", selector).booleanValue()) { + throw RuntimeException( + String.format( + "Missing selector %s for %s", + stringSelector, + stringCls + ) + ) + } + return invoke(cls, selector, *args) + } + + fun invoke(id: ID?, selector: String?, vararg args: Any?): ID { + return invoke(id, createSelector(selector), *args) + } + + fun invoke_fpret(receiver: ID?, selector: Pointer?, vararg args: Any?): Double { + return myObjcMsgSend.invokeDouble(prepInvoke(receiver, selector, args)) + } + + fun invoke_fpret(receiver: ID?, selector: String?, vararg args: Any?): Double { + return invoke_fpret(receiver, createSelector(selector), *args) + } + + fun isNil(id: ID?): Boolean { + return id == null || ID.NIL == id + } + + fun safeInvoke(id: ID, stringSelector: String?, vararg args: Any): ID { + val selector = createSelector(stringSelector) + if (id != ID.NIL && !invoke(id, "respondsToSelector:", selector).booleanValue()) { + throw RuntimeException( + String.format( + "Missing selector %s for %s", stringSelector, toStringViaUTF8( + invoke(id, "description") + ) + ) + ) + } + return invoke(id, selector, *args) + } + + fun allocateObjcClassPair(superCls: ID?, name: String?): ID? { + return myFoundationLibrary.objc_allocateClassPair(superCls, name, 0) + } + + fun registerObjcClassPair(cls: ID?) { + myFoundationLibrary.objc_registerClassPair(cls) + } + + fun isClassRespondsToSelector(cls: ID?, selectorName: Pointer?): Boolean { + return myFoundationLibrary.class_respondsToSelector(cls, selectorName) + } + + /** + * @param cls The class to which to add a method. + * @param selectorName A selector that specifies the name of the method being added. + * @param impl A function which is the implementation of the new method. The function must take at least two arguments-self and _cmd. + * @param types An array of characters that describe the types of the arguments to the method. + * See [](https://developer.apple.com/library/IOs/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html#//apple_ref/doc/uid/TP40008048-CH100) + * @return true if the method was added successfully, otherwise false (for example, the class already contains a method implementation with that name). + */ + fun addMethod(cls: ID?, selectorName: Pointer?, impl: Callback?, types: String?): Boolean { + return myFoundationLibrary.class_addMethod(cls, selectorName, impl, types) + } + + fun addProtocol(aClass: ID?, protocol: ID?): Boolean { + return myFoundationLibrary.class_addProtocol(aClass, protocol) + } + + fun addMethodByID(cls: ID?, selectorName: Pointer?, impl: ID?, types: String?): Boolean { + return myFoundationLibrary.class_addMethod(cls, selectorName, impl, types) + } + + fun isMetaClass(cls: ID?): Boolean { + return myFoundationLibrary.class_isMetaClass(cls) + } + + fun stringFromSelector(selector: Pointer?): String? { + val id = myFoundationLibrary.NSStringFromSelector(selector) + return if (ID.NIL == id) null else toStringViaUTF8(id) + } + + fun stringFromClass(aClass: ID?): String? { + val id = myFoundationLibrary.NSStringFromClass(aClass) + return if (ID.NIL == id) null else toStringViaUTF8(id) + } + + fun getClass(clazz: Pointer?): Pointer? { + return myFoundationLibrary.objc_getClass(clazz) + } + + fun fullUserName(): String? { + return toStringViaUTF8(myFoundationLibrary.NSFullUserName()) + } + + fun class_replaceMethod(cls: ID?, selector: Pointer?, impl: Callback?, types: String?): ID? { + return myFoundationLibrary.class_replaceMethod(cls, selector, impl, types) + } + + fun getMetaClass(className: String?): ID? { + return myFoundationLibrary.objc_getMetaClass(className) + } + + fun isPackageAtPath(path: String): Boolean { + val workspace = invoke("NSWorkspace", "sharedWorkspace") + val result = invoke(workspace, createSelector("isFilePackageAtPath:"), nsString(path)) + + return result.booleanValue() + } + + fun isPackageAtPath(file: File): Boolean { + if (!file.isDirectory) return false + return isPackageAtPath(file.path) + } + + fun nsString(s: String?): ID { + return if (s == null) ID.NIL else NSString.create(s) + } + + fun nsString(s: CharSequence?): ID { + return if (s == null) ID.NIL else NSString.create(s) + } + + fun nsUUID(uuid: UUID): ID { + return nsUUID(uuid.toString()) + } + + fun nsUUID(uuid: String): ID { + return invoke( + invoke(invoke("NSUUID", "alloc"), "initWithUUIDString:", nsString(uuid)), + "autorelease" + ) + } + + fun nsURL(path: String): ID { + return invoke("NSURL", "fileURLWithPath:", nsString(path)) + } + + fun toStringViaUTF8(cfString: ID?): String? { + if (ID.NIL == cfString) return null + + val lengthInChars = myFoundationLibrary.CFStringGetLength(cfString) + val potentialLengthInBytes = + 3 * lengthInChars + 1 // UTF8 fully escaped 16 bit chars, plus nul + + val buffer = ByteArray(potentialLengthInBytes) + val ok = myFoundationLibrary.CFStringGetCString( + cfString, + buffer, + buffer.size, + FoundationLibrary.kCFStringEncodingUTF8 + ) + if (ok.toInt() == 0) throw RuntimeException("Could not convert string") + return Native.toString(buffer) + } + + // @NlsSafe +// fun getNSErrorText(error: ID?): String? { +// if (error == null || error.toInt() == 0) return null +// +// var description = toStringViaUTF8(invoke(error, "localizedDescription")) +// val recovery = toStringViaUTF8(invoke(error, "localizedRecoverySuggestion")) +// if (recovery != null) description += """ +// +// $recovery +// """.trimIndent() +// return StringUtil.notNullize(description) +// } + + fun getEncodingName(nsStringEncoding: Long): String? { + val cfEncoding = + myFoundationLibrary.CFStringConvertNSStringEncodingToEncoding(nsStringEncoding) + val pointer = myFoundationLibrary.CFStringConvertEncodingToIANACharSetName(cfEncoding) + var name = toStringViaUTF8(pointer) + if ("macintosh" == name) name = + "MacRoman" // JDK8 does not recognize IANA's "macintosh" alias + + return name + } + +// fun getEncodingCode(encodingName: String?): Long { +// if (StringUtil.isEmptyOrSpaces(encodingName)) return -1 +// +// val converted = nsString(encodingName) +// val cfEncoding = myFoundationLibrary.CFStringConvertIANACharSetNameToEncoding(converted) +// +// val restored = myFoundationLibrary.CFStringConvertEncodingToIANACharSetName(cfEncoding) +// if (ID.NIL == restored) return -1 +// +// return convertCFEncodingToNS(cfEncoding) +// } + + private fun convertCFEncodingToNS(cfEncoding: Long): Long { + return myFoundationLibrary.CFStringConvertEncodingToNSStringEncoding(cfEncoding) and 0xffffffffffL // trim to C-type limits + } + + fun cfRetain(id: ID?) { + myFoundationLibrary.CFRetain(id) + } + + fun cfRelease(vararg ids: ID?) { + for (id in ids) { + if (id != null) { + myFoundationLibrary.CFRelease(id) + } + } + } + + fun autorelease(id: ID?): ID { + return invoke(id, "autorelease") + } + + val isMainThread: Boolean + get() = invoke("NSThread", "isMainThread").booleanValue() + + private var ourRunnableCallback: Callback? = null + private val ourMainThreadRunnables: MutableMap = HashMap() + private var ourCurrentRunnableCount: Long = 0 + private val RUNNABLE_LOCK = Any() + + fun executeOnMainThread( + withAutoreleasePool: Boolean, + waitUntilDone: Boolean, + runnable: Runnable + ) { + var runnableCountString: String? + synchronized(RUNNABLE_LOCK) { + initRunnableSupport() + runnableCountString = (++ourCurrentRunnableCount).toString() + ourMainThreadRunnables.put( + runnableCountString, + RunnableInfo(runnable, withAutoreleasePool) + ) + } + + // fixme: Use Grand Central Dispatch instead? + val ideaRunnable = getObjcClass("IdeaRunnable") + val runnableObject = invoke(invoke(ideaRunnable, "alloc"), "init") + val keyObject = invoke(nsString(runnableCountString), "retain") + invoke( + runnableObject, + "performSelectorOnMainThread:withObject:waitUntilDone:", + createSelector("run:"), + keyObject, + waitUntilDone + ) + invoke(runnableObject, "release") + } + + /** + * Registers idea runnable adapter class in ObjC runtime, if not registered yet. + * + * + * Warning: NOT THREAD-SAFE! Must be called under lock. Danger of segmentation fault. + */ + private fun initRunnableSupport() { + if (ourRunnableCallback == null) { + val runnableClass = allocateObjcClassPair(getObjcClass("NSObject"), "IdeaRunnable") + registerObjcClassPair(runnableClass) + + val callback: Callback = object : Callback { + fun callback(self: ID?, selector: String?, keyObject: ID?) { + val key = toStringViaUTF8(keyObject) + invoke(keyObject, "release") + + var info: RunnableInfo? + synchronized(RUNNABLE_LOCK) { + info = ourMainThreadRunnables.remove(key) + } + + if (info == null) { + return + } + + var pool: ID? = null + try { + if (info!!.myUseAutoreleasePool) { + pool = invoke("NSAutoreleasePool", "new") + } + + info!!.myRunnable.run() + } finally { + if (pool != null) { + invoke(pool, "release") + } + } + } + } + if (!addMethod(runnableClass, createSelector("run:"), callback, "v@:*")) { + throw RuntimeException("Unable to add method to objective-c runnableClass class!") + } + ourRunnableCallback = callback + } + } + + fun fillArray(a: Array): ID { + val result = invoke("NSMutableArray", "array") + for (s in a) { + invoke(result, "addObject:", convertType(s)) + } + + return result + } + + fun createDict(keys: Array, values: Array): ID { + val nsKeys = invokeVarArg("NSArray", "arrayWithObjects:", *convertTypes(keys.map { it }.toTypedArray())) + val nsData = invokeVarArg("NSArray", "arrayWithObjects:", *convertTypes(values)) + return invoke("NSDictionary", "dictionaryWithObjects:forKeys:", nsData, nsKeys) + } + + fun createPointerReference(): PointerType { + val reference: PointerType = PointerByReference(Memory(Native.POINTER_SIZE.toLong())) + reference.pointer.clear(Native.POINTER_SIZE.toLong()) + return reference + } + + fun castPointerToNSError(pointerType: PointerType): ID { + return ID(pointerType.pointer.getLong(0)) + } + + fun convertTypes(v: Array): Array { + val result = arrayOfNulls(v.size + 1) + for (i in v.indices) { + result[i] = convertType(v[i]) + } + result[v.size] = ID.NIL + return result + } + + private fun convertType(o: Any): Any { + return if (o is Pointer || o is ID) { + o + } else if (o is String) { + nsString(o) + } else { + throw IllegalArgumentException("Unsupported type! " + o.javaClass) + } + } + + private object NSString { + private val nsStringCls = getObjcClass("NSString") + private val stringSel = createSelector("string") + private val allocSel = createSelector("alloc") + private val autoreleaseSel = createSelector("autorelease") + private val initWithBytesLengthEncodingSel = + createSelector("initWithBytes:length:encoding:") + private val nsEncodingUTF16LE = + convertCFEncodingToNS(FoundationLibrary.kCFStringEncodingUTF16LE.toLong()) + + fun create(s: String): ID { + // Use a byte[] rather than letting jna do the String -> char* marshalling itself. + // Turns out about 10% quicker for long strings. + if (s.isEmpty()) { + return invoke(nsStringCls, stringSel) + } + + val utf16Bytes = s.toByteArray(StandardCharsets.UTF_16LE) + return create(utf16Bytes) + } + + fun create(cs: CharSequence): ID { + if (cs is String) { + return create(cs) + } + if (cs.isEmpty()) { + return invoke(nsStringCls, stringSel) + } + + val utf16Bytes = StandardCharsets.UTF_16LE.encode(CharBuffer.wrap(cs)).array() + return create(utf16Bytes) + } + + private fun create(utf16Bytes: ByteArray): ID { + val emptyNsString = invoke(nsStringCls, allocSel) + val initializedNsString = invoke( + emptyNsString, + initWithBytesLengthEncodingSel, + utf16Bytes, + utf16Bytes.size, + nsEncodingUTF16LE + ) + return invoke(initializedNsString, autoreleaseSel) + } + } + + internal class RunnableInfo(var myRunnable: Runnable, var myUseAutoreleasePool: Boolean) + + class NSDictionary(private val myDelegate: ID?) { + fun get(key: ID?): ID { + return invoke(myDelegate, "objectForKey:", key) + } + + fun get(key: String?): ID { + return get(nsString(key)) + } + + fun count(): Int { + return invoke(myDelegate, "count").toInt() + } + + fun keys(): NSArray { + return NSArray(invoke(myDelegate, "allKeys")) + } + + companion object { + fun toStringMap(delegate: ID?): Map { + val result: MutableMap = HashMap() + if (isNil(delegate)) { + return result + } + + val dict = NSDictionary(delegate) + val keys = dict.keys() + + for (i in 0 until keys.count()) { + val key = toStringViaUTF8(keys.at(i)) + val `val` = toStringViaUTF8(dict.get(key)) + result[key] = `val` + } + + return result + } + + fun toStringDictionary(map: Map): ID { + val dict = invoke("NSMutableDictionary", "dictionaryWithCapacity:", map.size) + for ((key, value) in map) { + invoke( + dict, "setObject:forKey:", nsString(value), nsString( + key + ) + ) + } + return dict + } + } + } + + class NSArray(private val myDelegate: ID) { + fun count(): Int { + return invoke(myDelegate, "count").toInt() + } + + fun at(index: Int): ID { + return invoke(myDelegate, "objectAtIndex:", index) + } + + val list: List + get() { + val result: MutableList = ArrayList() + for (i in 0 until count()) { + result.add(at(i)) + } + return result + } + } + +// class NSData // delegate should not be nil +// (private val myDelegate: ID) { +// fun length(): Int { +// return invoke(myDelegate, "length").toInt() +// } +// +// fun bytes(): ByteArray { +// val data = Pointer(invoke(myDelegate, "bytes").toLong()) +// return data.getByteArray(0, length()) +// } +// +// fun createImageFromBytes(): Image { +// return ImageLoader.loadFromBytes(bytes()) +// } +// } + + class NSAutoreleasePool { + private val myDelegate = + invoke(invoke("NSAutoreleasePool", "alloc"), "init") + + fun drain() { + invoke(myDelegate, "drain") + } + } + +// @Structure.FieldOrder("origin", "size") +// class NSRect(x: Double, y: Double, w: Double, h: Double) : Structure(), +// Structure.ByValue { +// var origin: NSPoint = NSPoint(x, y) +// var size: NSSize = NSSize(w, h) +// } +// +// @Structure.FieldOrder("x", "y") +// class NSPoint @JvmOverloads constructor(x: Double = 0.0, y: Double = 0.0) : +// Structure(), Structure.ByValue { +// var x: CoreGraphics.CGFloat = CGFloat(x) +// var y: CoreGraphics.CGFloat = CGFloat(y) +// } +// +// @Structure.FieldOrder("width", "height") +// class NSSize @JvmOverloads constructor(width: Double = 0.0, height: Double = 0.0) : +// Structure(), Structure.ByValue { +// var width: CoreGraphics.CGFloat = CGFloat(width) +// var height: CoreGraphics.CGFloat = CGFloat(height) +// } +} diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/mac/foundation/FoundationLibrary.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/mac/foundation/FoundationLibrary.kt new file mode 100644 index 0000000..4c95ad9 --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/mac/foundation/FoundationLibrary.kt @@ -0,0 +1,106 @@ +package io.github.vinceglb.filekit.core.platform.mac.foundation + +import com.sun.jna.Callback +import com.sun.jna.Library +import com.sun.jna.Pointer + +internal interface FoundationLibrary : Library { + fun NSLog(pString: Pointer?, thing: Any?) + + fun NSFullUserName(): ID? + + fun objc_allocateClassPair(supercls: ID?, name: String?, extraBytes: Int): ID? + fun objc_registerClassPair(cls: ID?) + + fun CFStringCreateWithBytes( + allocator: Pointer?, + bytes: ByteArray?, + byteCount: Int, + encoding: Int, + isExternalRepresentation: Byte + ): ID? + + fun CFStringGetCString(theString: ID?, buffer: ByteArray?, bufferSize: Int, encoding: Int): Byte + fun CFStringGetLength(theString: ID?): Int + + fun CFStringConvertNSStringEncodingToEncoding(nsEncoding: Long): Long + fun CFStringConvertEncodingToIANACharSetName(cfEncoding: Long): ID? + + fun CFStringConvertIANACharSetNameToEncoding(encodingName: ID?): Long + fun CFStringConvertEncodingToNSStringEncoding(cfEncoding: Long): Long + + fun CFRetain(cfTypeRef: ID?) + fun CFRelease(cfTypeRef: ID?) + fun CFGetRetainCount(cfTypeRef: Pointer?): Int + + fun objc_getClass(className: String?): ID? + fun objc_getProtocol(name: String?): ID? + + fun class_createInstance(pClass: ID?, extraBytes: Int): ID? + fun sel_registerName(selectorName: String?): Pointer? + + fun class_replaceMethod(cls: ID?, selName: Pointer?, impl: Callback?, types: String?): ID? + + fun objc_getMetaClass(name: String?): ID? + + /** + * Note: Vararg version. Should only be used only for selectors with a single fixed argument followed by varargs. + */ + fun objc_msgSend(receiver: ID?, selector: Pointer?, firstArg: Any?, vararg args: Any?): ID? + + fun class_respondsToSelector(cls: ID?, selName: Pointer?): Boolean + fun class_addMethod(cls: ID?, selName: Pointer?, imp: Callback?, types: String?): Boolean + + fun class_addMethod(cls: ID?, selName: Pointer?, imp: ID?, types: String?): Boolean + fun class_addProtocol(aClass: ID?, protocol: ID?): Boolean + + fun class_isMetaClass(cls: ID?): Boolean + + fun NSStringFromSelector(selector: Pointer?): ID? + fun NSStringFromClass(aClass: ID?): ID? + + fun objc_getClass(clazz: Pointer?): Pointer? + + companion object { + const val kCFStringEncodingMacRoman: Int = 0 + const val kCFStringEncodingWindowsLatin1: Int = 0x0500 + const val kCFStringEncodingISOLatin1: Int = 0x0201 + const val kCFStringEncodingNextStepLatin: Int = 0x0B01 + const val kCFStringEncodingASCII: Int = 0x0600 + const val kCFStringEncodingUnicode: Int = 0x0100 + const val kCFStringEncodingUTF8: Int = 0x08000100 + const val kCFStringEncodingNonLossyASCII: Int = 0x0BFF + + const val kCFStringEncodingUTF16: Int = 0x0100 + const val kCFStringEncodingUTF16BE: Int = 0x10000100 + const val kCFStringEncodingUTF16LE: Int = 0x14000100 + const val kCFStringEncodingUTF32: Int = 0x0c000100 + const val kCFStringEncodingUTF32BE: Int = 0x18000100 + const val kCFStringEncodingUTF32LE: Int = 0x1c000100 + + // https://developer.apple.com/library/mac/documentation/Carbon/Reference/CGWindow_Reference/Constants/Constants.html#//apple_ref/doc/constant_group/Window_List_Option_Constants + const val kCGWindowListOptionAll: Int = 0 + const val kCGWindowListOptionOnScreenOnly: Int = 1 + const val kCGWindowListOptionOnScreenAboveWindow: Int = 2 + const val kCGWindowListOptionOnScreenBelowWindow: Int = 4 + const val kCGWindowListOptionIncludingWindow: Int = 8 + const val kCGWindowListExcludeDesktopElements: Int = 16 + + //https://developer.apple.com/library/mac/documentation/Carbon/Reference/CGWindow_Reference/Constants/Constants.html#//apple_ref/doc/constant_group/Window_Image_Types + const val kCGWindowImageDefault: Int = 0 + const val kCGWindowImageBoundsIgnoreFraming: Int = 1 + const val kCGWindowImageShouldBeOpaque: Int = 2 + const val kCGWindowImageOnlyShadows: Int = 4 + const val kCGWindowImageBestResolution: Int = 8 + const val kCGWindowImageNominalResolution: Int = 16 + + + // see enum NSBitmapImageFileType + const val NSBitmapImageFileTypeTIFF: Int = 0 + const val NSBitmapImageFileTypeBMP: Int = 1 + const val NSBitmapImageFileTypeGIF: Int = 2 + const val NSBitmapImageFileTypeJPEG: Int = 3 + const val NSBitmapImageFileTypePNG: Int = 4 + const val NSBitmapImageFileTypeJPEG2000: Int = 5 + } +} diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/mac/foundation/ID.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/mac/foundation/ID.kt new file mode 100644 index 0000000..78c8057 --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/mac/foundation/ID.kt @@ -0,0 +1,28 @@ +package io.github.vinceglb.filekit.core.platform.mac.foundation + +import com.sun.jna.NativeLong + +/** + * Could be an address in memory (if pointer to a class or method) or a value (like 0 or 1) + */ +internal class ID : NativeLong { + constructor() + + constructor(peer: Long) : super(peer) + + fun booleanValue(): Boolean { + return toInt() != 0 + } + + override fun toByte(): Byte { + return toLong().toByte() + } + + override fun toShort(): Short { + return toLong().toShort() + } + + companion object { + val NIL: ID = ID(0L) + } +} diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/util/Platform.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/util/Platform.kt new file mode 100644 index 0000000..8c15d1a --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/util/Platform.kt @@ -0,0 +1,27 @@ +package com.mohamedrejeb.calf.picker.platform.util + +internal object PlatformUtil { + val current: Platform + get() { + val system = System.getProperty("os.name").lowercase() + return when { + system.contains("win") -> + Platform.Windows + + system.contains("nix") || system.contains("nux") || system.contains("aix") -> + Platform.Linux + + system.contains("mac") -> + Platform.MacOS + + else -> + Platform.Linux + } + } +} + +internal enum class Platform { + Linux, + MacOS, + Windows +} diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/WindowsFilePicker.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/WindowsFilePicker.kt new file mode 100644 index 0000000..77cd122 --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/WindowsFilePicker.kt @@ -0,0 +1,106 @@ +package com.mohamedrejeb.calf.picker.platform.windows + +import com.mohamedrejeb.calf.picker.FilePickerFileType +import com.mohamedrejeb.calf.picker.FilePickerSelectionMode +import com.mohamedrejeb.calf.picker.platform.PlatformFilePicker +import com.mohamedrejeb.calf.picker.platform.windows.api.JnaFileChooser +import jodd.net.MimeTypes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.awt.Window +import java.io.File + +internal class WindowsFilePicker: PlatformFilePicker { + override suspend fun launchFilePicker( + initialDirectory: String?, + type: FilePickerFileType, + selectionMode: FilePickerSelectionMode, + title: String?, + parentWindow: Window?, + onResult: (List) -> Unit, + ) = withContext(Dispatchers.Default) { + val fileChooser = JnaFileChooser() + + // Setup file chooser + fileChooser.apply { + // Set mode + mode = + if (type == FilePickerFileType.Folder) + JnaFileChooser.Mode.Directories + else + JnaFileChooser.Mode.Files + + // Set selection mode + isMultiSelectionEnabled = selectionMode == FilePickerSelectionMode.Multiple + + // Set initial directory, title and file extensions + val fileExtensions = + if (type is FilePickerFileType.Extension) + type.extensions + else + type.value + .map { + MimeTypes.findExtensionsByMimeTypes(it, it.contains('*')) + } + .flatten() + .distinct() + setup(initialDirectory, fileExtensions, title) + } + + // Show file chooser + fileChooser.showOpenDialog(parentWindow) + + // Return selected files + val result = + if (selectionMode == FilePickerSelectionMode.Single) + listOfNotNull(fileChooser.selectedFile) + else + fileChooser.selectedFiles.mapNotNull { it } + onResult(result) + } + + override suspend fun launchDirectoryPicker( + initialDirectory: String?, + title: String?, + parentWindow: Window?, + onResult: (File?) -> Unit, + ) = withContext(Dispatchers.Default) { + val fileChooser = JnaFileChooser() + + // Setup file chooser + fileChooser.apply { + // Set mode + mode = JnaFileChooser.Mode.Directories + + // Only allow single selection + isMultiSelectionEnabled = false + + // Set initial directory and title + setup(initialDirectory, emptyList(), title) + } + + // Show file chooser + fileChooser.showOpenDialog(parentWindow) + + // Return selected directory + onResult(fileChooser.selectedFile) + } + + private fun JnaFileChooser.setup( + initialDirectory: String?, + fileExtensions: List, + title: String? + ) { + // Set title + title?.let(::setTitle) + + // Set initial directory + initialDirectory?.let(::setCurrentDirectory) + + // Set file extension + if (fileExtensions.isNotEmpty()) { + val filterName = fileExtensions.joinToString(", ", "Supported Files (", ")") + addFilter(filterName, *fileExtensions.toTypedArray()) + } + } +} diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/api/JnaFileChooser.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/api/JnaFileChooser.kt new file mode 100644 index 0000000..1e5c006 --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/api/JnaFileChooser.kt @@ -0,0 +1,296 @@ +package com.mohamedrejeb.calf.picker.platform.windows.api + +import com.sun.jna.Platform +import java.awt.Window +import java.io.File +import java.util.Arrays +import java.util.Collections +import javax.swing.JFileChooser +import javax.swing.filechooser.FileNameExtensionFilter + +/** + * JnaFileChooser is a wrapper around the native Windows file chooser + * and folder browser that falls back to the Swing JFileChooser on platforms + * other than Windows or if the user chooses a combination of features + * that are not supported by the native dialogs (for example multiple + * selection of directories). + * + * Example: + * JnaFileChooser fc = new JnaFileChooser(); + * fc.setFilter("All Files", "*"); + * fc.setFilter("Pictures", "jpg", "jpeg", "gif", "png", "bmp"); + * fc.setMultiSelectionEnabled(true); + * fc.setMode(JnaFileChooser.Mode.FilesAndDirectories); + * if (fc.showOpenDialog(parent)) { + * Files[] selected = fc.getSelectedFiles(); + * // do something with selected + * } + * + * @see JFileChooser, WindowsFileChooser, WindowsFileBrowser + */ +internal class JnaFileChooser() { + private enum class Action { + Open, Save + } + + /** + * the availabe selection modes of the dialog + */ + enum class Mode(val jFileChooserValue: Int) { + Files(JFileChooser.FILES_ONLY), + Directories(JFileChooser.DIRECTORIES_ONLY), + FilesAndDirectories(JFileChooser.FILES_AND_DIRECTORIES) + } + + var selectedFiles: Array + protected set + var currentDirectory: File? = null + protected set + protected var filters: ArrayList> = ArrayList() + + /** + * sets whether to enable multiselection + * + * @param enabled true to enable multiselection, false to disable it + */ + var isMultiSelectionEnabled: Boolean = false + + /** + * sets the selection mode + * + * @param mode the selection mode + */ + var mode: Mode = Mode.Files + + private var defaultFile: String = "" + private var dialogTitle: String = "" + private var openButtonText: String = "" + private var saveButtonText: String = "" + + /** + * creates a new file chooser with multiselection disabled and mode set + * to allow file selection only. + */ + init { + selectedFiles = arrayOf(null) + } + + /** + * creates a new file chooser with the specified initial directory + * + * @param currentDirectory the initial directory + */ + constructor(currentDirectory: File?) : this() { + if (currentDirectory != null) { + this.currentDirectory = + if (currentDirectory.isDirectory) currentDirectory else currentDirectory.parentFile + } + } + + /** + * creates a new file chooser with the specified initial directory + * + * @param currentDirectory the initial directory + */ + constructor(currentDirectoryPath: String?) : this( + if (currentDirectoryPath != null) File( + currentDirectoryPath + ) else null + ) + + /** + * shows a dialog for opening files + * + * @param parent the parent window + * + * @return true if the user clicked OK + */ + fun showOpenDialog(parent: Window?): Boolean { + return showDialog(parent, Action.Open) + } + + /** + * shows a dialog for saving files + * + * @param parent the parent window + * + * @return true if the user clicked OK + */ + fun showSaveDialog(parent: Window): Boolean { + return showDialog(parent, Action.Save) + } + + private fun showDialog(parent: Window?, action: Action): Boolean { + // native windows filechooser doesn't support mixed selection mode + if (Platform.isWindows() && mode != Mode.FilesAndDirectories) { + // windows filechooser can only multiselect files + if (isMultiSelectionEnabled && mode == Mode.Files) { + // TODO Here we would use the native windows dialog + // to choose multiple files. However I haven't been able + // to get it to work properly yet because it requires + // tricky callback magic and somehow this didn't work for me + // quite as documented (probably because I messed something up). + // Because I don't need this feature right now I've put it on + // hold to get on with stuff. + // Example code: http://support.microsoft.com/kb/131462/en-us + // GetOpenFileName: http://msdn.microsoft.com/en-us/library/ms646927.aspx + // OFNHookProc: http://msdn.microsoft.com/en-us/library/ms646931.aspx + // CDN_SELCHANGE: http://msdn.microsoft.com/en-us/library/ms646865.aspx + // SendMessage: http://msdn.microsoft.com/en-us/library/ms644950.aspx + } else if (!isMultiSelectionEnabled) { + if (mode == Mode.Files) { + return showWindowsFileChooser(parent, action) + } else if (mode == Mode.Directories) { + return showWindowsFolderBrowser(parent) + } + } + } + + // fallback to Swing + return showSwingFileChooser(parent, action) + } + + private fun showSwingFileChooser(parent: Window?, action: Action): Boolean { + val fc = JFileChooser(currentDirectory) + fc.isMultiSelectionEnabled = isMultiSelectionEnabled + fc.fileSelectionMode = mode.jFileChooserValue + + // set select file + if (defaultFile.isNotEmpty() and (action == Action.Save)) { + val fsel = File(defaultFile) + fc.selectedFile = fsel + } + if (dialogTitle.isNotEmpty()) { + fc.dialogTitle = dialogTitle + } + if ((action == Action.Open) and openButtonText.isNotEmpty()) { + fc.approveButtonText = openButtonText + } else if ((action == Action.Save) and saveButtonText.isNotEmpty()) { + fc.approveButtonText = saveButtonText + } + + // build filters + if (filters.size > 0) { + var useAcceptAllFilter = false + for (spec in filters) { + // the "All Files" filter is handled specially by JFileChooser + if (spec[1] == "*") { + useAcceptAllFilter = true + continue + } + fc.addChoosableFileFilter( + FileNameExtensionFilter( + spec[0], *Arrays.copyOfRange(spec, 1, spec.size) + ) + ) + } + fc.isAcceptAllFileFilterUsed = useAcceptAllFilter + } + + var result = -1 + result = if (action == Action.Open) { + fc.showOpenDialog(parent) + } else { + if (saveButtonText.isEmpty()) { + fc.showSaveDialog(parent) + } else { + fc.showDialog(parent, null) + } + } + if (result == JFileChooser.APPROVE_OPTION) { + selectedFiles = + if (isMultiSelectionEnabled) fc.selectedFiles else arrayOf(fc.selectedFile) + currentDirectory = fc.currentDirectory + return true + } + + return false + } + + private fun showWindowsFileChooser(parent: Window?, action: Action): Boolean { + val fc = WindowsFileChooser(currentDirectory) + fc.setFilters(filters) + + if (defaultFile.isNotEmpty()) fc.setDefaultFilename(defaultFile) + + if (dialogTitle.isNotEmpty()) { + fc.setTitle(dialogTitle) + } + + val result = fc.showDialog(parent, action == Action.Open) + if (result) { + selectedFiles = arrayOf(fc.selectedFile) + currentDirectory = fc.currentDirectory + } + return result + } + + private fun showWindowsFolderBrowser(parent: Window?): Boolean { + val fb = WindowsFolderBrowser() + if (!dialogTitle.isEmpty()) { + fb.setTitle(dialogTitle) + } + val file = fb.showDialog(parent) + if (file != null) { + selectedFiles = arrayOf(file) + currentDirectory = if (file.parentFile != null) file.parentFile else file + return true + } + + return false + } + + /** + * add a filter to the user-selectable list of file filters + * + * @param name name of the filter + * @param filter you must pass at least 1 argument, the arguments are the file + * extensions. + */ + fun addFilter(name: String, vararg filter: String) { + require(filter.isNotEmpty()) + val parts = ArrayList() + parts.add(name) + Collections.addAll(parts, *filter) + filters.add(parts.toTypedArray()) + } + + fun setCurrentDirectory(currentDirectoryPath: String?) { + this.currentDirectory = + (if (currentDirectoryPath != null) File(currentDirectoryPath) else null) + } + + fun setDefaultFileName(dfile: String) { + this.defaultFile = dfile + } + + /** + * set a title name + * + * @param Title of dialog + */ + fun setTitle(title: String) { + this.dialogTitle = title + } + + /** + * set a open button name + * + * @param open button text + */ + fun setOpenButtonText(buttonText: String) { + this.openButtonText = buttonText + } + + /** + * set a saveFile button name + * + * @param saveFile button text + */ + fun setSaveButtonText(buttonText: String) { + this.saveButtonText = buttonText + } + + val selectedFile: File? + get() = selectedFiles[0] +} diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/api/WindowsFileChooser.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/api/WindowsFileChooser.kt new file mode 100644 index 0000000..154977f --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/api/WindowsFileChooser.kt @@ -0,0 +1,299 @@ +package com.mohamedrejeb.calf.picker.platform.windows.api + +import com.mohamedrejeb.calf.picker.platform.windows.win32.Comdlg32 +import com.mohamedrejeb.calf.picker.platform.windows.win32.Comdlg32.CommDlgExtendedError +import com.mohamedrejeb.calf.picker.platform.windows.win32.Comdlg32.GetOpenFileNameW +import com.mohamedrejeb.calf.picker.platform.windows.win32.Comdlg32.GetSaveFileNameW +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.WString +import java.awt.Window +import java.io.File +import java.util.Collections + +/** + * The native Windows file chooser dialog. + * + * Example: + * WindowsFileChooser fc = new WindowsFileChooser("C:\\"); + * fc.addFilter("All Files", "*"); + * fc.addFilter("Text files", "txt", "log", "xml", "css", "html"); + * fc.addFilter("Source code", "java", "c", "cpp", "cc", "h", "hpp"); + * fc.addFilter("Binary files", "exe", "class", "jar", "dll", "so"); + * if (fc.showOpenDialog(parent)) { + * File f = fc.getSelectedFile(); + * // do something with f + * } + * + * Note that although you can set the initial directory Windows will + * determine the initial directory according to the following rules + * (the initial directory is referred to as "lpstrInitialDir"): + * + * Windows 7: + * 1. If lpstrInitialDir has the same value as was passed the first time the + * application used an Open or Save As dialog box, the path most recently + * selected by the user is used as the initial directory. + * 2. Otherwise, if lpstrFile contains a path, that path is the initial + * directory. + * 3. Otherwise, if lpstrInitialDir is not NULL, it specifies the initial + * directory. + * 4. If lpstrInitialDir is NULL and the current directory contains any files of + * the specified filter types, the initial directory is the current + * directory. + * 5. Otherwise, the initial directory is the personal files directory of the + * current user. + * 6. Otherwise, the initial directory is the Desktop folder. + * + * Windows 2000/XP/Vista: + * 1. If lpstrFile contains a path, that path is the initial directory. + * 2. Otherwise, lpstrInitialDir specifies the initial directory. + * 3. Otherwise, if the application has used an Open or Save As dialog box in + * the past, the path most recently used is selected as the initial + * directory. However, if an application is not run for a long time, its + * saved selected path is discarded. + * 4. If lpstrInitialDir is NULL and the current directory contains any files + * of the specified filter types, the initial directory is the current + * directory. + * 5. Otherwise, the initial directory is the personal files directory of the + * current user. + * 6. Otherwise, the initial directory is the Desktop folder. + * + * Therefore you probably want to use an exe wrapper like WinRun4J in order + * for this to work properly on Windows 7. Otherwise multiple programs may + * interfere with each other. Unfortunately there doesn't seem to be a way + * to override this behaviour. + * + * [://msdn.microsoft.com/en-us/library/ms646839.aspx][http] + * [://winrun4j.sourceforge.net/][http] + */ +internal class WindowsFileChooser { + /** + * returns the file selected by the user + * + * @return the selected file; null if the dialog was canceled or never shown + */ + var selectedFile: File? = null + protected set + + /** + * returns the current directory + * + * This is always the parent directory of the chosen file, even if you + * enter an absolute path to a file that doesn't exist in the current + * directory. + * + * @return the current directory + */ + var currentDirectory: File? = null + protected set + private var filters: ArrayList> + + private var defaultFilename: String = "" + private var dialogTitle: String = "" + + /** + * creates a new file chooser + */ + constructor() { + filters = ArrayList() + } + + /** + * creates a new file chooser with the specified initial directory + * + * If the given file is not a directory the parent file will be used instead. + * + * @param currentDirectory the initial directory + */ + constructor(currentDirectory: File?) { + filters = ArrayList() + if (currentDirectory != null) { + this.currentDirectory = + if (currentDirectory.isDirectory) currentDirectory else currentDirectory.parentFile + } + } + + /** + * creates a new file chooser with the specified initial directory path + * + * @param currentDirectoryPath the initial directory path; may be null + */ + constructor(currentDirectoryPath: String?) : this( + if (currentDirectoryPath != null) File( + currentDirectoryPath + ) else null + ) + + // this is a package private method used by the JnaFileChooser + // facade to directly set the filter list + fun setFilters(filters: ArrayList>) { + this.filters = filters + } + + /** + * add a filter to the user-selectable list of file filters + * + * @param name name of the filter + * @param filter you must pass at least 1 argument, the arguments + * are the file extensions. + */ + fun addFilter(name: String, vararg filter: String) { + require(filter.size >= 1) + val parts = mutableListOf() + parts.add(name) + Collections.addAll(parts, *filter) + filters.add(parts.toTypedArray()) + } + + /** + * set a title name + * + * @param Title of dialog + */ + fun setTitle(tname: String) { + this.dialogTitle = tname + } + + /** + * show the dialog for opening a file + * + * @param parent the parent window of the dialog + * + * @return true if the user clicked ok, false otherwise + */ + fun showOpenDialog(parent: Window?): Boolean { + return showDialog(parent, true) + } + + /** + * show the dialog for saving a file + * + * @param parent the parent window of the dialog + * + * @return true if the user clicked ok, false otherwise + */ + fun showSaveDialog(parent: Window?): Boolean { + return showDialog(parent, false) + } + + /* + * shows the dialog + * + * @param parent the parent window + * @param open whether to show the open dialog, if false saveFile dialog is shown + * + * @return true if the user clicked ok, false otherwise + */ + fun showDialog(parent: Window?, open: Boolean): Boolean { + val params = Comdlg32.OpenFileName() + params.Flags = // use explorer-style interface + (Comdlg32.OFN_EXPLORER // the dialog changes the current directory when browsing, + // this flag causes the original value to be restored after the + // dialog returns + or Comdlg32.OFN_NOCHANGEDIR // disable "open as read-only" feature + or Comdlg32.OFN_HIDEREADONLY // enable resizing of the dialog + or Comdlg32.OFN_ENABLESIZING) + + params.hwndOwner = if (parent == null) null else Native.getWindowPointer(parent) + + // lpstrFile contains the selection path after the dialog + // returns. It must be big enough for the path to fit or + // GetOpenFileName returns an error (FNERR_BUFFERTOOSMALL). + // MAX_PATH is 260 so 4*260+1 bytes should be big enough (I hope...) + // http://msdn.microsoft.com/en-us/library/aa365247.aspx#maxpath + val bufferLength = 260 + // 4 bytes per char + 1 null byte + val bufferSize = 4 * bufferLength + 1 + params.lpstrFile = Memory(bufferSize.toLong()) + if (defaultFilename.isNotEmpty()) { + params.lpstrFile?.setWideString(0, defaultFilename) + } else { + params.lpstrFile?.clear(bufferSize.toLong()) + } + if (!dialogTitle.isEmpty()) { + params.lpstrTitle = WString(dialogTitle) + } + + // nMaxFile + // http://msdn.microsoft.com/en-us/library/ms646839.aspx: + // "The size, in characters, of the buffer pointed to by + // lpstrFile. The buffer must be large enough to store the + // path and file name string or strings, including the + // terminating NULL character." + + // Therefore because we're using the unicode version of the + // API the nMaxFile value must be 1/4 of the lpstrFile + // buffer size plus one for the terminating null byte. + params.nMaxFile = bufferLength + + if (currentDirectory != null) { + params.lpstrInitialDir = WString(currentDirectory!!.absolutePath) + } + + // build filter string if filters were specified + if (filters.size > 0) { + params.lpstrFilter = WString(buildFilterString()) + params.nFilterIndex = 1 // TODO don't hardcode here + } + + val approved = if (open) GetOpenFileNameW(params) else GetSaveFileNameW(params) + + if (approved) { + val filePath = params.lpstrFile?.getWideString(0) + selectedFile = File(filePath) + val dir = selectedFile!!.parentFile + currentDirectory = dir + } else { + val errCode = CommDlgExtendedError() + // if the code is 0 the user clicked cancel + if (errCode != 0) { + throw RuntimeException( + "GetOpenFileName failed with error $errCode" + ) + } + } + return approved + } + + /* + * builds a filter string + * + * from MSDN: + * A buffer containing pairs of null-terminated filter strings. The last + * string in the buffer must be terminated by two NULL characters. + * + * The first string in each pair is a display string that describes the + * filter (for example, "Text Files"), and the second string specifies the + * filter pattern (for example, "*.TXT"). To specify multiple filter + * patterns for a single display string, use a semicolon to separate the + * patterns (for example, "*.TXT;*.DOC;*.BAK"). + * + * http://msdn.microsoft.com/en-us/library/ms646839.aspx + */ + private fun buildFilterString(): String { + val filterStr = StringBuilder() + for (spec in filters) { + val label = spec[0] + // add label and terminate with null byte + filterStr.append(label) + filterStr.append('\u0000') + // build file extension patterns seperated by a + // semicolon and terminated by a null byte + for (i in 1 until spec.size) { + filterStr.append("*.") + filterStr.append(spec[i]) + filterStr.append(';') + } + // remove last superfluous ";" and add terminator + filterStr.deleteCharAt(filterStr.length - 1) + filterStr.append('\u0000') + } + // final terminator + filterStr.append('\u0000') + return filterStr.toString() + } + + fun setDefaultFilename(defaultFilename: String) { + this.defaultFilename = defaultFilename + } +} diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/api/WindowsFolderBrowser.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/api/WindowsFolderBrowser.kt new file mode 100644 index 0000000..51503c3 --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/api/WindowsFolderBrowser.kt @@ -0,0 +1,81 @@ +package com.mohamedrejeb.calf.picker.platform.windows.api + +import com.mohamedrejeb.calf.picker.platform.windows.win32.Ole32.CoTaskMemFree +import com.mohamedrejeb.calf.picker.platform.windows.win32.Ole32.OleInitialize +import com.mohamedrejeb.calf.picker.platform.windows.win32.Shell32 +import com.mohamedrejeb.calf.picker.platform.windows.win32.Shell32.SHBrowseForFolder +import com.mohamedrejeb.calf.picker.platform.windows.win32.Shell32.SHGetPathFromIDListW +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.Pointer +import java.awt.Window +import java.io.File + +/** + * The native Windows folder browser. + * + * Example: + * WindowsFolderBrowser fb = new WindowsFolderBrowser(); + * File dir = fb.showDialog(parentWindow); + * if (dir != null) { + * // do something with dir + * } + */ +internal class WindowsFolderBrowser { + private var title: String? + + /** + * creates a new folder browser + */ + constructor() { + title = null + } + + /** + * creates a new folder browser with text that can be used as title + * or to give instructions to the user + * + * @param title text that will be displayed at the top of the dialog + */ + constructor(title: String?) { + this.title = title + } + + fun setTitle(title: String?) { + this.title = title + } + + /** + * displays the dialog to the user + * + * @param parent the parent window + * + * @return the selected directory or null if the user canceled the dialog + */ + fun showDialog(parent: Window?): File? { + OleInitialize(null) + val params = Shell32.BrowseInfo() + params.hwndOwner = if (parent == null) null else Native.getWindowPointer(parent) + params.ulFlags = // disable the OK button if the user selects a virtual PIDL + Shell32.BIF_RETURNONLYFSDIRS or // BIF_USENEWUI is only available as of Windows 2000/Me (Shell32.dll 5.0) + // but I guess no one is using older versions anymore anyway right?! + // I don't know what happens if this is executed where it's + // not supported. + Shell32.BIF_USENEWUI + if (title != null) { + params.lpszTitle = title + } + val pidl = SHBrowseForFolder(params) + if (pidl != null) { + // MAX_PATH is 260 on Windows XP x32 so 4kB should + // be more than big enough + val path: Pointer = Memory((1024 * 4).toLong()) + SHGetPathFromIDListW(pidl, path) + val filePath = path.getWideString(0) + val file = File(filePath) + CoTaskMemFree(pidl) + return file + } + return null + } +} diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/win32/Comdlg32.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/win32/Comdlg32.kt new file mode 100644 index 0000000..fb7ec75 --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/win32/Comdlg32.kt @@ -0,0 +1,111 @@ +package com.mohamedrejeb.calf.picker.platform.windows.win32 + +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.WString + +internal object Comdlg32 { + init { + Native.register("comdlg32") + } + + external fun GetOpenFileNameW(params: OpenFileName?): Boolean + external fun GetSaveFileNameW(params: OpenFileName?): Boolean + external fun CommDlgExtendedError(): Int + + // flags for the OpenFileName structure + const val OFN_READONLY: Int = 0x00000001 + const val OFN_OVERWRITEPROMPT: Int = 0x00000002 + const val OFN_HIDEREADONLY: Int = 0x00000004 + const val OFN_NOCHANGEDIR: Int = 0x00000008 + const val OFN_SHOWHELP: Int = 0x00000010 + const val OFN_ENABLEHOOK: Int = 0x00000020 + const val OFN_ENABLETEMPLATE: Int = 0x00000040 + const val OFN_ENABLETEMPLATEHANDLE: Int = 0x00000080 + const val OFN_NOVALIDATE: Int = 0x00000100 + const val OFN_ALLOWMULTISELECT: Int = 0x00000200 + const val OFN_EXTENSIONDIFFERENT: Int = 0x00000400 + const val OFN_PATHMUSTEXIST: Int = 0x00000800 + const val OFN_FILEMUSTEXIST: Int = 0x00001000 + const val OFN_CREATEPROMPT: Int = 0x00002000 + const val OFN_SHAREAWARE: Int = 0x00004000 + const val OFN_NOREADONLYRETURN: Int = 0x00008000 + const val OFN_NOTESTFILECREATE: Int = 0x00010000 + const val OFN_NONETWORKBUTTON: Int = 0x00020000 + const val OFN_NOLONGNAMES: Int = 0x00040000 + const val OFN_EXPLORER: Int = 0x00080000 + const val OFN_NODEREFERENCELINKS: Int = 0x00100000 + const val OFN_LONGNAMES: Int = 0x00200000 + const val OFN_ENABLEINCLUDENOTIFY: Int = 0x00400000 + const val OFN_ENABLESIZING: Int = 0x00800000 + const val OFN_DONTADDTORECENT: Int = 0x02000000 + const val OFN_FORCESHOWHIDDEN: Int = 0x10000000 + + // error codes from cderr.h which may be returned by + // CommDlgExtendedError for the GetOpenFileName and + // GetSaveFileName functions. + const val CDERR_DIALOGFAILURE: Int = 0xFFFF + const val CDERR_FINDRESFAILURE: Int = 0x0006 + const val CDERR_INITIALIZATION: Int = 0x0002 + const val CDERR_LOADRESFAILURE: Int = 0x0007 + const val CDERR_LOADSTRFAILURE: Int = 0x0005 + const val CDERR_LOCKRESFAILURE: Int = 0x0008 + const val CDERR_MEMALLOCFAILURE: Int = 0x0009 + const val CDERR_MEMLOCKFAILURE: Int = 0x000A + const val CDERR_NOHINSTANCE: Int = 0x0004 + const val CDERR_NOHOOK: Int = 0x000B + const val CDERR_NOTEMPLATE: Int = 0x0003 + const val CDERR_STRUCTSIZE: Int = 0x0001 + const val FNERR_SUBCLASSFAILURE: Int = 0x3001 + const val FNERR_INVALIDFILENAME: Int = 0x3002 + const val FNERR_BUFFERTOOSMALL: Int = 0x3003 + + class OpenFileName : Structure() { + @JvmField var lStructSize: Int = size() + @JvmField var hwndOwner: Pointer? = null + @JvmField var hInstance: Pointer? = null + @JvmField var lpstrFilter: WString? = null + @JvmField var lpstrCustomFilter: WString? = null + @JvmField var nMaxCustFilter: Int = 0 + @JvmField var nFilterIndex: Int = 0 + @JvmField var lpstrFile: Pointer? = null + @JvmField var nMaxFile: Int = 0 + @JvmField var lpstrDialogTitle: String? = null + @JvmField var nMaxDialogTitle: Int = 0 + @JvmField var lpstrInitialDir: WString? = null + @JvmField var lpstrTitle: WString? = null + @JvmField var Flags: Int = 0 + @JvmField var nFileOffset: Short = 0 + @JvmField var nFileExtension: Short = 0 + @JvmField var lpstrDefExt: String? = null + @JvmField var lCustData: Pointer? = null + @JvmField var lpfnHook: Pointer? = null + @JvmField var lpTemplateName: Pointer? = null + + override fun getFieldOrder(): List { + return listOf( + "lStructSize", + "hwndOwner", + "hInstance", + "lpstrFilter", + "lpstrCustomFilter", + "nMaxCustFilter", + "nFilterIndex", + "lpstrFile", + "nMaxFile", + "lpstrDialogTitle", + "nMaxDialogTitle", + "lpstrInitialDir", + "lpstrTitle", + "Flags", + "nFileOffset", + "nFileExtension", + "lpstrDefExt", + "lCustData", + "lpfnHook", + "lpTemplateName" + ) + } + } +} diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/win32/Ole32.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/win32/Ole32.kt new file mode 100644 index 0000000..5683c8c --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/win32/Ole32.kt @@ -0,0 +1,13 @@ +package com.mohamedrejeb.calf.picker.platform.windows.win32 + +import com.sun.jna.Native +import com.sun.jna.Pointer + +internal object Ole32 { + init { + Native.register("ole32") + } + + external fun OleInitialize(pvReserved: Pointer?): Pointer? + external fun CoTaskMemFree(pv: Pointer?) +} diff --git a/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/win32/Shell32.kt b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/win32/Shell32.kt new file mode 100644 index 0000000..f2b7a7a --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/com/mohamedrejeb/calf/picker/platform/windows/win32/Shell32.kt @@ -0,0 +1,50 @@ +package com.mohamedrejeb.calf.picker.platform.windows.win32 + +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure + +internal object Shell32 { + init { + Native.register("shell32") + } + + external fun SHBrowseForFolder(params: BrowseInfo?): Pointer? + external fun SHGetPathFromIDListW(pidl: Pointer?, path: Pointer?): Boolean + + // flags for the BrowseInfo structure + const val BIF_RETURNONLYFSDIRS: Int = 0x00000001 + const val BIF_DONTGOBELOWDOMAIN: Int = 0x00000002 + const val BIF_NEWDIALOGSTYLE: Int = 0x00000040 + const val BIF_EDITBOX: Int = 0x00000010 + const val BIF_USENEWUI: Int = BIF_EDITBOX or BIF_NEWDIALOGSTYLE + const val BIF_NONEWFOLDERBUTTON: Int = 0x00000200 + const val BIF_BROWSEINCLUDEFILES: Int = 0x00004000 + const val BIF_SHAREABLE: Int = 0x00008000 + const val BIF_BROWSEFILEJUNCTIONS: Int = 0x00010000 + + // http://msdn.microsoft.com/en-us/library/bb773205.aspx + class BrowseInfo : Structure() { + @JvmField var hwndOwner: Pointer? = null + @JvmField var pidlRoot: Pointer? = null + @JvmField var pszDisplayName: String? = null + @JvmField var lpszTitle: String? = null + @JvmField var ulFlags: Int = 0 + @JvmField var lpfn: Pointer? = null + @JvmField var lParam: Pointer? = null + @JvmField var iImage: Int = 0 + + override fun getFieldOrder(): List { + return listOf( + "hwndOwner", + "pidlRoot", + "pszDisplayName", + "lpszTitle", + "ulFlags", + "lpfn", + "lParam", + "iImage" + ) + } + } +} diff --git a/calf-file-picker/src/desktopMain/kotlin/jodd/io/IOUtil.kt b/calf-file-picker/src/desktopMain/kotlin/jodd/io/IOUtil.kt new file mode 100644 index 0000000..2a660f5 --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/jodd/io/IOUtil.kt @@ -0,0 +1,56 @@ +// Copyright (c) 2003-present, Jodd Team (http://jodd.org) +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +package jodd.io + +import java.io.Closeable +import java.io.Flushable +import java.io.IOException + +/** + * Optimized byte and character stream utilities. + */ +object IOUtil { + // ---------------------------------------------------------------- silent close + /** + * Closes silently the closable object. If it is [Flushable], it + * will be flushed first. No exception will be thrown if an I/O error occurs. + */ + fun close(closeable: Closeable?) { + if (closeable == null) + return + + if (closeable is Flushable) { + try { + closeable.flush() + } catch (ignored: IOException) { + } + } + + try { + closeable.close() + } catch (ignored: IOException) { + } + } +} \ No newline at end of file diff --git a/calf-file-picker/src/desktopMain/kotlin/jodd/net/MimeTypes.kt b/calf-file-picker/src/desktopMain/kotlin/jodd/net/MimeTypes.kt new file mode 100644 index 0000000..51d62ae --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/jodd/net/MimeTypes.kt @@ -0,0 +1,168 @@ +// Copyright (c) 2003-present, Jodd Team (http://jodd.org) +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +package jodd.net + +import jodd.io.IOUtil +import jodd.util.Wildcard +import java.io.IOException +import java.util.Properties +import java.util.Locale + +/** + * Map file extensions to MIME types. Based on the most recent Apache mime.types file. + * Duplicated extensions (wmz, sub) are manually resolved. + * + * + * See also: + * http://www.iana.org/assignments/media-types/ + * http://www.webmaster-toolkit.com/mime-types.shtml + */ +object MimeTypes { + const val MIME_APPLICATION_ATOM_XML: String = "application/atom+xml" + const val MIME_APPLICATION_JAVASCRIPT: String = "application/javascript" + const val MIME_APPLICATION_JSON: String = "application/json" + const val MIME_APPLICATION_OCTET_STREAM: String = "application/octet-stream" + const val MIME_APPLICATION_XML: String = "application/xml" + const val MIME_TEXT_CSS: String = "text/css" + const val MIME_TEXT_PLAIN: String = "text/plain" + const val MIME_TEXT_HTML: String = "text/html" + + private val MIME_TYPE_MAP: LinkedHashMap // extension -> mime-type map + + init { + val mimes = Properties() + + val inputStream = MimeTypes::class.java.getResourceAsStream(MimeTypes::class.java.simpleName + ".properties") + ?: throw IllegalStateException("Mime types file missing") + + try { + mimes.load(inputStream) + } catch (ioex: IOException) { + throw IllegalStateException("Can't load properties", ioex) + } finally { + IOUtil.close(inputStream) + } + + MIME_TYPE_MAP = LinkedHashMap(mimes.size * 2) + + val keys = mimes.propertyNames() + while (keys.hasMoreElements()) { + var mimeType = keys.nextElement() as String + val extensions = mimes.getProperty(mimeType) + + when { + mimeType.startsWith("/") -> + mimeType = "application$mimeType" + + mimeType.startsWith("a/") -> + mimeType = "audio" + mimeType.substring(1) + + mimeType.startsWith("i/") -> + mimeType = "image" + mimeType.substring(1) + + mimeType.startsWith("t/") -> + mimeType = "text" + mimeType.substring(1) + + mimeType.startsWith("v/") -> + mimeType = "video" + mimeType.substring(1) + + } + + val allExtensions = extensions.split(' ') + + for (extension in allExtensions) { + require(MIME_TYPE_MAP.put(extension, mimeType) == null) { "Duplicated extension: $extension" } + } + } + } + + /** + * Registers MIME type for provided extension. Existing extension type will be overridden. + */ + fun registerMimeType(ext: String, mimeType: String) { + MIME_TYPE_MAP[ext] = mimeType + } + + /** + * Returns the corresponding MIME type to the given extension. + * If no MIME type was found it returns `application/octet-stream` type. + */ + fun getMimeType(ext: String): String { + var mimeType = lookupMimeType(ext) + if (mimeType == null) { + mimeType = MIME_APPLICATION_OCTET_STREAM + } + return mimeType + } + + /** + * Simply returns MIME type or `null` if no type is found. + */ + fun lookupMimeType(ext: String): String? { + return MIME_TYPE_MAP[ext.lowercase(Locale.getDefault())] + } + + /** + * Finds all extensions that belong to given mime type(s). + * If wildcard mode is on, provided mime type is wildcard pattern. + * @param mimeType list of mime types, separated by comma + * @param useWildcard if set, mime types are wildcard patterns + */ + fun findExtensionsByMimeTypes(mimeType: String, useWildcard: Boolean): List { + var mimeType = mimeType + val extensions = mutableListOf() + + mimeType = mimeType.lowercase(Locale.getDefault()) + val mimeTypes = mimeType.split(", ") + + for ((entryExtension, value) in MIME_TYPE_MAP) { + val entryMimeType = value.lowercase(Locale.getDefault()) + + entryMimeType.findAnyOf(mimeTypes) + val matchResult = + if (useWildcard) + Wildcard.matchOne(entryMimeType, *mimeTypes.toTypedArray()) + else + mimeTypes.indexOf(entryMimeType) + + if (matchResult != -1) { + extensions.add(entryExtension) + } + } + + if (extensions.isEmpty()) { + return emptyList() + } + + return extensions.toList() + } + + /** + * Returns `true` if given value is one of the registered MIME extensions. + */ + fun isRegisteredExtension(extension: String): Boolean { + return MIME_TYPE_MAP.containsKey(extension) + } +} \ No newline at end of file diff --git a/calf-file-picker/src/desktopMain/kotlin/jodd/util/Wildcard.kt b/calf-file-picker/src/desktopMain/kotlin/jodd/util/Wildcard.kt new file mode 100644 index 0000000..d06f57d --- /dev/null +++ b/calf-file-picker/src/desktopMain/kotlin/jodd/util/Wildcard.kt @@ -0,0 +1,142 @@ +// Copyright (c) 2003-present, Jodd Team (http://jodd.org) +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +package jodd.util + +/** + * Checks whether a string or path matches a given wildcard pattern. + * Possible patterns allow to match single characters ('?') or any count of + * characters ('*'). Wildcard characters can be escaped (by an '\'). + * When matching path, deep tree wildcard also can be used ('**'). + * + * + * This method uses recursive matching, as in linux or windows. regexp works the same. + * This method is very fast, comparing to similar implementations. + */ +object Wildcard { + /** + * Checks whether a string matches a given wildcard pattern. + * + * @param string input string + * @param pattern pattern to match + * @return `true` if string matches the pattern, otherwise `false` + */ + fun match(string: CharSequence, pattern: CharSequence): Boolean { + return match(string, pattern, 0, 0) + } + + /** + * Internal matching recursive function. + */ + private fun match(string: CharSequence, pattern: CharSequence, sNdx: Int, pNdx: Int): Boolean { + var sNdx = sNdx + var pNdx = pNdx + val pLen = pattern.length + if (pLen == 1) { + if (pattern[0] == '*') { // speed-up + return true + } + } + val sLen = string.length + var nextIsNotWildcard = false + + while (true) { + // check if end of string and/or pattern occurred + + if ((sNdx >= sLen)) { // end of string still may have pending '*' in pattern + while ((pNdx < pLen) && (pattern[pNdx] == '*')) { + pNdx++ + } + return pNdx >= pLen + } + if (pNdx >= pLen) { // end of pattern, but not end of the string + return false + } + val p = pattern[pNdx] // pattern char + + // perform logic + if (!nextIsNotWildcard) { + if (p == '\\') { + pNdx++ + nextIsNotWildcard = true + continue + } + if (p == '?') { + sNdx++ + pNdx++ + continue + } + if (p == '*') { + var pNext = 0.toChar() // next pattern char + if (pNdx + 1 < pLen) { + pNext = pattern[pNdx + 1] + } + if (pNext == '*') { // double '*' have the same effect as one '*' + pNdx++ + continue + } + pNdx++ + + // find recursively if there is any substring from the end of the + // line that matches the rest of the pattern !!! + var i = string.length + while (i >= sNdx) { + if (match(string, pattern, i, pNdx)) { + return true + } + i-- + } + return false + } + } else { + nextIsNotWildcard = false + } + + // check if pattern char and string char are equals + if (p != string[sNdx]) { + return false + } + + // everything matches for now, continue + sNdx++ + pNdx++ + } + } + + + // ---------------------------------------------------------------- utilities + /** + * Matches string to at least one pattern. + * Returns index of matched pattern, or `-1` otherwise. + * @see .match + */ + fun matchOne(src: String, vararg patterns: String): Int { + for (i in patterns.indices) { + if (match(src, patterns[i])) { + return i + } + } + return -1 + } +} \ No newline at end of file diff --git a/calf-file-picker/src/desktopMain/resources/jodd/net/MimeTypes.properties b/calf-file-picker/src/desktopMain/resources/jodd/net/MimeTypes.properties new file mode 100644 index 0000000..114b03b --- /dev/null +++ b/calf-file-picker/src/desktopMain/resources/jodd/net/MimeTypes.properties @@ -0,0 +1,789 @@ +# Copyright (c) 2003-present, Jodd Team (http://jodd.org) +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. +/andrew-inset=ez +/applixware=aw +/atom+xml=atom +/atomcat+xml=atomcat +/atomsvc+xml=atomsvc +/ccxml+xml=ccxml +/cdmi-capability=cdmia +/cdmi-container=cdmic +/cdmi-domain=cdmid +/cdmi-object=cdmio +/cdmi-queue=cdmiq +/cu-seeme=cu +/davmount+xml=davmount +/docbook+xml=dbk +/dssc+der=dssc +/dssc+xml=xdssc +/ecmascript=ecma +/emma+xml=emma +/epub+zip=epub +/exi=exi +/font-tdpfr=pfr +/gml+xml=gml +/gpx+xml=gpx +/gxf=gxf +/hyperstudio=stk +/inkml+xml=ink inkml +/ipfix=ipfix +/java-archive=jar +/java-serialized-object=ser +/java-vm=class +/javascript=js +/json=json +/jsonml+json=jsonml +/lost+xml=lostxml +/mac-binhex40=hqx +/mac-compactpro=cpt +/mads+xml=mads +/marc=mrc +/marcxml+xml=mrcx +/mathematica=ma nb mb +/mathml+xml=mathml +/mbox=mbox +/mediaservercontrol+xml=mscml +/metalink+xml=metalink +/metalink4+xml=meta4 +/mets+xml=mets +/mods+xml=mods +/mp21=m21 mp21 +/mp4=mp4s +/msword=doc dot +/mxf=mxf +/octet-stream=bin dms lrf mar so dist distz pkg bpk dump elc deploy +/oda=oda +/oebps-package+xml=opf +/ogg=ogx +/omdoc+xml=omdoc +/onenote=onetoc onetoc2 onetmp onepkg +/oxps=oxps +/patch-ops-error+xml=xer +/pdf=pdf +/pgp-encrypted=pgp +/pgp-signature=asc sig +/pics-rules=prf +/pkcs10=p10 +/pkcs7-mime=p7m p7c +/pkcs7-signature=p7s +/pkcs8=p8 +/pkix-attr-cert=ac +/pkix-cert=cer +/pkix-crl=crl +/pkix-pkipath=pkipath +/pkixcmp=pki +/pls+xml=pls +/postscript=ai eps ps +/prs.cww=cww +/pskc+xml=pskcxml +/rdf+xml=rdf +/reginfo+xml=rif +/relax-ng-compact-syntax=rnc +/resource-lists+xml=rl +/resource-lists-diff+xml=rld +/rls-services+xml=rs +/rpki-ghostbusters=gbr +/rpki-manifest=mft +/rpki-roa=roa +/rsd+xml=rsd +/rss+xml=rss +/rtf=rtf +/sbml+xml=sbml +/scvp-cv-request=scq +/scvp-cv-response=scs +/scvp-vp-request=spq +/scvp-vp-response=spp +/sdp=sdp +/set-payment-initiation=setpay +/set-registration-initiation=setreg +/shf+xml=shf +/smil+xml=smi smil +/sparql-query=rq +/sparql-results+xml=srx +/srgs=gram +/srgs+xml=grxml +/sru+xml=sru +/ssdl+xml=ssdl +/ssml+xml=ssml +/tei+xml=tei teicorpus +/thraud+xml=tfi +/timestamped-data=tsd +/vnd.3gpp.pic-bw-large=plb +/vnd.3gpp.pic-bw-small=psb +/vnd.3gpp.pic-bw-var=pvb +/vnd.3gpp2.tcap=tcap +/vnd.3m.post-it-notes=pwn +/vnd.accpac.simply.aso=aso +/vnd.accpac.simply.imp=imp +/vnd.acucobol=acu +/vnd.acucorp=atc acutc +/vnd.adobe.air-application-installer-package+zip=air +/vnd.adobe.formscentral.fcdt=fcdt +/vnd.adobe.fxp=fxp fxpl +/vnd.adobe.xdp+xml=xdp +/vnd.adobe.xfdf=xfdf +/vnd.ahead.space=ahead +/vnd.airzip.filesecure.azf=azf +/vnd.airzip.filesecure.azs=azs +/vnd.amazon.ebook=azw +/vnd.americandynamics.acc=acc +/vnd.amiga.ami=ami +/vnd.android.package-archive=apk +/vnd.anser-web-certificate-issue-initiation=cii +/vnd.anser-web-funds-transfer-initiation=fti +/vnd.antix.game-component=atx +/vnd.apple.installer+xml=mpkg +/vnd.apple.mpegurl=m3u8 +/vnd.aristanetworks.swi=swi +/vnd.astraea-software.iota=iota +/vnd.audiograph=aep +/vnd.blueice.multipass=mpm +/vnd.bmi=bmi +/vnd.businessobjects=rep +/vnd.chemdraw+xml=cdxml +/vnd.chipnuts.karaoke-mmd=mmd +/vnd.cinderella=cdy +/vnd.claymore=cla +/vnd.cloanto.rp9=rp9 +/vnd.clonk.c4group=c4g c4d c4f c4p c4u +/vnd.cluetrust.cartomobile-config=c11amc +/vnd.cluetrust.cartomobile-config-pkg=c11amz +/vnd.commonspace=csp +/vnd.contact.cmsg=cdbcmsg +/vnd.cosmocaller=cmc +/vnd.crick.clicker=clkx +/vnd.crick.clicker.keyboard=clkk +/vnd.crick.clicker.palette=clkp +/vnd.crick.clicker.template=clkt +/vnd.crick.clicker.wordbank=clkw +/vnd.criticaltools.wbs+xml=wbs +/vnd.ctc-posml=pml +/vnd.cups-ppd=ppd +/vnd.curl.car=car +/vnd.curl.pcurl=pcurl +/vnd.dart=dart +/vnd.data-vision.rdz=rdz +/vnd.dece.data=uvf uvvf uvd uvvd +/vnd.dece.ttml+xml=uvt uvvt +/vnd.dece.unspecified=uvx uvvx +/vnd.dece.zip=uvz uvvz +/vnd.denovo.fcselayout-link=fe_launch +/vnd.dna=dna +/vnd.dolby.mlp=mlp +/vnd.dpgraph=dpg +/vnd.dreamfactory=dfac +/vnd.ds-keypoint=kpxx +/vnd.dvb.ait=ait +/vnd.dvb.service=svc +/vnd.dynageo=geo +/vnd.ecowin.chart=mag +/vnd.enliven=nml +/vnd.epson.esf=esf +/vnd.epson.msf=msf +/vnd.epson.quickanime=qam +/vnd.epson.salt=slt +/vnd.epson.ssf=ssf +/vnd.eszigno3+xml=es3 et3 +/vnd.ezpix-album=ez2 +/vnd.ezpix-package=ez3 +/vnd.fdf=fdf +/vnd.fdsn.mseed=mseed +/vnd.fdsn.seed=seed dataless +/vnd.flographit=gph +/vnd.fluxtime.clip=ftc +/vnd.framemaker=fm frame maker book +/vnd.frogans.fnc=fnc +/vnd.frogans.ltf=ltf +/vnd.fsc.weblaunch=fsc +/vnd.fujitsu.oasys=oas +/vnd.fujitsu.oasys2=oa2 +/vnd.fujitsu.oasys3=oa3 +/vnd.fujitsu.oasysgp=fg5 +/vnd.fujitsu.oasysprs=bh2 +/vnd.fujixerox.ddd=ddd +/vnd.fujixerox.docuworks=xdw +/vnd.fujixerox.docuworks.binder=xbd +/vnd.fuzzysheet=fzs +/vnd.genomatix.tuxedo=txd +/vnd.geogebra.file=ggb +/vnd.geogebra.tool=ggt +/vnd.geometry-explorer=gex gre +/vnd.geonext=gxt +/vnd.geoplan=g2w +/vnd.geospace=g3w +/vnd.gmx=gmx +/vnd.google-earth.kml+xml=kml +/vnd.google-earth.kmz=kmz +/vnd.grafeq=gqf gqs +/vnd.groove-account=gac +/vnd.groove-help=ghf +/vnd.groove-identity-message=gim +/vnd.groove-injector=grv +/vnd.groove-tool-message=gtm +/vnd.groove-tool-template=tpl +/vnd.groove-vcard=vcg +/vnd.hal+xml=hal +/vnd.handheld-entertainment+xml=zmm +/vnd.hbci=hbci +/vnd.hhe.lesson-player=les +/vnd.hp-hpgl=hpgl +/vnd.hp-hpid=hpid +/vnd.hp-hps=hps +/vnd.hp-jlyt=jlt +/vnd.hp-pcl=pcl +/vnd.hp-pclxl=pclxl +/vnd.hydrostatix.sof-data=sfd-hdstx +/vnd.ibm.minipay=mpy +/vnd.ibm.modcap=afp listafp list3820 +/vnd.ibm.rights-management=irm +/vnd.ibm.secure-container=sc +/vnd.iccprofile=icc icm +/vnd.igloader=igl +/vnd.immervision-ivp=ivp +/vnd.immervision-ivu=ivu +/vnd.insors.igm=igm +/vnd.intercon.formnet=xpw xpx +/vnd.intergeo=i2g +/vnd.intu.qbo=qbo +/vnd.intu.qfx=qfx +/vnd.ipunplugged.rcprofile=rcprofile +/vnd.irepository.package+xml=irp +/vnd.is-xpr=xpr +/vnd.isac.fcs=fcs +/vnd.jam=jam +/vnd.jcp.javame.midlet-rms=rms +/vnd.jisp=jisp +/vnd.joost.joda-archive=joda +/vnd.kahootz=ktz ktr +/vnd.kde.karbon=karbon +/vnd.kde.kchart=chrt +/vnd.kde.kformula=kfo +/vnd.kde.kivio=flw +/vnd.kde.kontour=kon +/vnd.kde.kpresenter=kpr kpt +/vnd.kde.kspread=ksp +/vnd.kde.kword=kwd kwt +/vnd.kenameaapp=htke +/vnd.kidspiration=kia +/vnd.kinar=kne knp +/vnd.koan=skp skd skt skm +/vnd.kodak-descriptor=sse +/vnd.las.las+xml=lasxml +/vnd.llamagraphics.life-balance.desktop=lbd +/vnd.llamagraphics.life-balance.exchange+xml=lbe +/vnd.lotus-1-2-3=123 +/vnd.lotus-approach=apr +/vnd.lotus-freelance=pre +/vnd.lotus-notes=nsf +/vnd.lotus-organizer=org +/vnd.lotus-screencam=scm +/vnd.lotus-wordpro=lwp +/vnd.macports.portpkg=portpkg +/vnd.mcd=mcd +/vnd.medcalcdata=mc1 +/vnd.mediastation.cdkey=cdkey +/vnd.mfer=mwf +/vnd.mfmp=mfm +/vnd.micrografx.flo=flo +/vnd.micrografx.igx=igx +/vnd.mif=mif +/vnd.mobius.daf=daf +/vnd.mobius.dis=dis +/vnd.mobius.mbk=mbk +/vnd.mobius.mqy=mqy +/vnd.mobius.msl=msl +/vnd.mobius.plc=plc +/vnd.mobius.txf=txf +/vnd.mophun.application=mpn +/vnd.mophun.certificate=mpc +/vnd.mozilla.xul+xml=xul +/vnd.ms-artgalry=cil +/vnd.ms-cab-compressed=cab +/vnd.ms-excel=xls xlm xla xlc xlt xlw +/vnd.ms-excel.addin.macroenabled.12=xlam +/vnd.ms-excel.sheet.binary.macroenabled.12=xlsb +/vnd.ms-excel.sheet.macroenabled.12=xlsm +/vnd.ms-excel.template.macroenabled.12=xltm +/vnd.ms-fontobject=eot +/vnd.ms-htmlhelp=chm +/vnd.ms-ims=ims +/vnd.ms-lrm=lrm +/vnd.ms-officetheme=thmx +/vnd.ms-pki.seccat=cat +/vnd.ms-pki.stl=stl +/vnd.ms-powerpoint=ppt pps pot +/vnd.ms-powerpoint.addin.macroenabled.12=ppam +/vnd.ms-powerpoint.presentation.macroenabled.12=pptm +/vnd.ms-powerpoint.slide.macroenabled.12=sldm +/vnd.ms-powerpoint.slideshow.macroenabled.12=ppsm +/vnd.ms-powerpoint.template.macroenabled.12=potm +/vnd.ms-project=mpp mpt +/vnd.ms-word.document.macroenabled.12=docm +/vnd.ms-word.template.macroenabled.12=dotm +/vnd.ms-works=wps wks wcm wdb +/vnd.ms-wpl=wpl +/vnd.ms-xpsdocument=xps +/vnd.mseq=mseq +/vnd.musician=mus +/vnd.muvee.style=msty +/vnd.mynfc=taglet +/vnd.neurolanguage.nlu=nlu +/vnd.nitf=ntf nitf +/vnd.noblenet-directory=nnd +/vnd.noblenet-sealer=nns +/vnd.noblenet-web=nnw +/vnd.nokia.n-gage.data=ngdat +/vnd.nokia.n-gage.symbian.install=n-gage +/vnd.nokia.radio-preset=rpst +/vnd.nokia.radio-presets=rpss +/vnd.novadigm.edm=edm +/vnd.novadigm.edx=edx +/vnd.novadigm.ext=ext +/vnd.oasis.opendocument.chart=odc +/vnd.oasis.opendocument.chart-template=otc +/vnd.oasis.opendocument.database=odb +/vnd.oasis.opendocument.formula=odf +/vnd.oasis.opendocument.formula-template=odft +/vnd.oasis.opendocument.graphics=odg +/vnd.oasis.opendocument.graphics-template=otg +/vnd.oasis.opendocument.image=odi +/vnd.oasis.opendocument.image-template=oti +/vnd.oasis.opendocument.presentation=odp +/vnd.oasis.opendocument.presentation-template=otp +/vnd.oasis.opendocument.spreadsheet=ods +/vnd.oasis.opendocument.spreadsheet-template=ots +/vnd.oasis.opendocument.text=odt +/vnd.oasis.opendocument.text-master=odm +/vnd.oasis.opendocument.text-template=ott +/vnd.oasis.opendocument.text-web=oth +/vnd.olpc-sugar=xo +/vnd.oma.dd2+xml=dd2 +/vnd.openofficeorg.extension=oxt +/vnd.openxmlformats-officedocument.presentationml.presentation=pptx +/vnd.openxmlformats-officedocument.presentationml.slide=sldx +/vnd.openxmlformats-officedocument.presentationml.slideshow=ppsx +/vnd.openxmlformats-officedocument.presentationml.template=potx +/vnd.openxmlformats-officedocument.spreadsheetml.sheet=xlsx +/vnd.openxmlformats-officedocument.spreadsheetml.template=xltx +/vnd.openxmlformats-officedocument.wordprocessingml.document=docx +/vnd.openxmlformats-officedocument.wordprocessingml.template=dotx +/vnd.osgeo.mapguide.package=mgp +/vnd.osgi.dp=dp +/vnd.osgi.subsystem=esa +/vnd.palm=pdb pqa oprc +/vnd.pawaafile=paw +/vnd.pg.format=str +/vnd.pg.osasli=ei6 +/vnd.picsel=efif +/vnd.pmi.widget=wg +/vnd.pocketlearn=plf +/vnd.powerbuilder6=pbd +/vnd.previewsystems.box=box +/vnd.proteus.magazine=mgz +/vnd.publishare-delta-tree=qps +/vnd.pvi.ptid1=ptid +/vnd.quark.quarkxpress=qxd qxt qwd qwt qxl qxb +/vnd.realvnc.bed=bed +/vnd.recordare.musicxml=mxl +/vnd.recordare.musicxml+xml=musicxml +/vnd.rig.cryptonote=cryptonote +/vnd.rim.cod=cod +/vnd.rn-realmedia=rm +/vnd.rn-realmedia-vbr=rmvb +/vnd.route66.link66+xml=link66 +/vnd.sailingtracker.track=st +/vnd.seemail=see +/vnd.sema=sema +/vnd.semd=semd +/vnd.semf=semf +/vnd.shana.informed.formdata=ifm +/vnd.shana.informed.formtemplate=itp +/vnd.shana.informed.interchange=iif +/vnd.shana.informed.package=ipk +/vnd.simtech-mindmapper=twd twds +/vnd.smaf=mmf +/vnd.smart.teacher=teacher +/vnd.solent.sdkm+xml=sdkm sdkd +/vnd.spotfire.dxp=dxp +/vnd.spotfire.sfs=sfs +/vnd.stardivision.calc=sdc +/vnd.stardivision.draw=sda +/vnd.stardivision.impress=sdd +/vnd.stardivision.math=smf +/vnd.stardivision.writer=sdw vor +/vnd.stardivision.writer-global=sgl +/vnd.stepmania.package=smzip +/vnd.stepmania.stepchart=sm +/vnd.sun.xml.calc=sxc +/vnd.sun.xml.calc.template=stc +/vnd.sun.xml.draw=sxd +/vnd.sun.xml.draw.template=std +/vnd.sun.xml.impress=sxi +/vnd.sun.xml.impress.template=sti +/vnd.sun.xml.math=sxm +/vnd.sun.xml.writer=sxw +/vnd.sun.xml.writer.global=sxg +/vnd.sun.xml.writer.template=stw +/vnd.sus-calendar=sus susp +/vnd.svd=svd +/vnd.symbian.install=sis sisx +/vnd.syncml+xml=xsm +/vnd.syncml.dm+wbxml=bdm +/vnd.syncml.dm+xml=xdm +/vnd.tao.intent-module-archive=tao +/vnd.tcpdump.pcap=pcap cap dmp +/vnd.tmobile-livetv=tmo +/vnd.trid.tpt=tpt +/vnd.triscape.mxs=mxs +/vnd.trueapp=tra +/vnd.ufdl=ufd ufdl +/vnd.uiq.theme=utz +/vnd.umajin=umj +/vnd.unity=unityweb +/vnd.uoml+xml=uoml +/vnd.vcx=vcx +/vnd.visio=vsd vst vss vsw +/vnd.visionary=vis +/vnd.vsf=vsf +/vnd.wap.wbxml=wbxml +/vnd.wap.wmlc=wmlc +/vnd.wap.wmlscriptc=wmlsc +/vnd.webturbo=wtb +/vnd.wolfram.player=nbp +/vnd.wordperfect=wpd +/vnd.wqd=wqd +/vnd.wt.stf=stf +/vnd.xara=xar +/vnd.xfdl=xfdl +/vnd.yamaha.hv-dic=hvd +/vnd.yamaha.hv-script=hvs +/vnd.yamaha.hv-voice=hvp +/vnd.yamaha.openscoreformat=osf +/vnd.yamaha.openscoreformat.osfpvg+xml=osfpvg +/vnd.yamaha.smaf-audio=saf +/vnd.yamaha.smaf-phrase=spf +/vnd.yellowriver-custom-menu=cmp +/vnd.zul=zir zirz +/vnd.zzazz.deck+xml=zaz +/voicexml+xml=vxml +/widget=wgt +/winhlp=hlp +/wsdl+xml=wsdl +/wspolicy+xml=wspolicy +/x-7z-compressed=7z +/x-abiword=abw +/x-ace-compressed=ace +/x-apple-diskimage=dmg +/x-authorware-bin=aab x32 u32 vox +/x-authorware-map=aam +/x-authorware-seg=aas +/x-bcpio=bcpio +/x-bittorrent=torrent +/x-blorb=blb blorb +/x-bzip=bz +/x-bzip2=bz2 boz +/x-cbr=cbr cba cbt cbz cb7 +/x-cdlink=vcd +/x-cfs-compressed=cfs +/x-chat=chat +/x-chess-pgn=pgn +/x-conference=nsc +/x-cpio=cpio +/x-csh=csh +/x-debian-package=deb udeb +/x-dgc-compressed=dgc +/x-director=dir dcr dxr cst cct cxt w3d fgd swa +/x-doom=wad +/x-dtbncx+xml=ncx +/x-dtbook+xml=dtb +/x-dtbresource+xml=res +/x-dvi=dvi +/x-envoy=evy +/x-eva=eva +/x-font-bdf=bdf +/x-font-ghostscript=gsf +/x-font-linux-psf=psf +/x-font-otf=otf +/x-font-pcf=pcf +/x-font-snf=snf +/x-font-ttf=ttf ttc +/x-font-type1=pfa pfb pfm afm +/x-font-woff=woff +/x-freearc=arc +/x-futuresplash=spl +/x-gca-compressed=gca +/x-glulx=ulx +/x-gnumeric=gnumeric +/x-gramps-xml=gramps +/x-gtar=gtar +/x-hdf=hdf +/x-install-instructions=install +/x-iso9660-image=iso +/x-java-jnlp-file=jnlp +/x-latex=latex +/x-lzh-compressed=lzh lha +/x-mie=mie +/x-mobipocket-ebook=prc mobi +/x-ms-application=application +/x-ms-shortcut=lnk +/x-ms-wmd=wmd +#/x-ms-wmz=wmz +/x-ms-xbap=xbap +/x-msaccess=mdb +/x-msbinder=obd +/x-mscardfile=crd +/x-msclip=clp +/x-msdownload=exe dll com bat msi +/x-msmediaview=mvb m13 m14 +/x-msmetafile=wmf wmz emf emz +/x-msmoney=mny +/x-mspublisher=pub +/x-msschedule=scd +/x-msterminal=trm +/x-mswrite=wri +/x-netcdf=nc cdf +/x-nzb=nzb +/x-pkcs12=p12 pfx +/x-pkcs7-certificates=p7b spc +/x-pkcs7-certreqresp=p7r +/x-rar-compressed=rar +/x-research-info-systems=ris +/x-sh=sh +/x-shar=shar +/x-shockwave-flash=swf +/x-silverlight-app=xap +/x-sql=sql +/x-stuffit=sit +/x-stuffitx=sitx +/x-subrip=srt +/x-sv4cpio=sv4cpio +/x-sv4crc=sv4crc +/x-t3vm-image=t3 +/x-tads=gam +/x-tar=tar +/x-tcl=tcl +/x-tex=tex +/x-tex-tfm=tfm +/x-texinfo=texinfo texi +/x-tgif=obj +/x-ustar=ustar +/x-wais-source=src +/x-x509-ca-cert=der crt +/x-xfig=fig +/x-xliff+xml=xlf +/x-xpinstall=xpi +/x-xz=xz +/x-zmachine=z1 z2 z3 z4 z5 z6 z7 z8 +/xaml+xml=xaml +/xcap-diff+xml=xdf +/xenc+xml=xenc +/xhtml+xml=xhtml xht +/xml=xml xsl +/xml-dtd=dtd +/xop+xml=xop +/xproc+xml=xpl +/xslt+xml=xslt +/xspf+xml=xspf +/xv+xml=mxml xhvml xvml xvm +/yang=yang +/yin+xml=yin +/zip=zip +a/adpcm=adp +a/basic=au snd +a/midi=mid midi kar rmi +a/mp4=mp4a +a/mpeg=mpga mp2 mp2a mp3 m2a m3a +a/ogg=oga ogg spx +a/s3m=s3m +a/silk=sil +a/vnd.dece.audio=uva uvva +a/vnd.digital-winds=eol +a/vnd.dra=dra +a/vnd.dts=dts +a/vnd.dts.hd=dtshd +a/vnd.lucent.voice=lvp +a/vnd.ms-playready.media.pya=pya +a/vnd.nuera.ecelp4800=ecelp4800 +a/vnd.nuera.ecelp7470=ecelp7470 +a/vnd.nuera.ecelp9600=ecelp9600 +a/vnd.rip=rip +a/webm=weba +a/x-aac=aac +a/x-aiff=aif aiff aifc +a/x-caf=caf +a/x-flac=flac +a/x-matroska=mka +a/x-mpegurl=m3u +a/x-ms-wax=wax +a/x-ms-wma=wma +a/x-pn-realaudio=ram ra +a/x-pn-realaudio-plugin=rmp +a/x-wav=wav +a/xm=xm +chemical/x-cdx=cdx +chemical/x-cif=cif +chemical/x-cmdf=cmdf +chemical/x-cml=cml +chemical/x-csml=csml +chemical/x-xyz=xyz +i/bmp=bmp +i/cgm=cgm +i/g3fax=g3 +i/gif=gif +i/ief=ief +i/jpeg=jpeg jpg jpe +i/ktx=ktx +i/png=png +i/prs.btif=btif +i/sgi=sgi +i/svg+xml=svg svgz +i/tiff=tiff tif +i/vnd.adobe.photoshop=psd +i/vnd.dece.graphic=uvi uvvi uvg uvvg +#i/vnd.dvb.subtitle=sub +i/vnd.djvu=djvu djv +i/vnd.dwg=dwg +i/vnd.dxf=dxf +i/vnd.fastbidsheet=fbs +i/vnd.fpx=fpx +i/vnd.fst=fst +i/vnd.fujixerox.edmics-mmr=mmr +i/vnd.fujixerox.edmics-rlc=rlc +i/vnd.ms-modi=mdi +i/vnd.ms-photo=wdp +i/vnd.net-fpx=npx +i/vnd.wap.wbmp=wbmp +i/vnd.xiff=xif +i/webp=webp +i/x-3ds=3ds +i/x-cmu-raster=ras +i/x-cmx=cmx +i/x-freehand=fh fhc fh4 fh5 fh7 +i/x-icon=ico +i/x-mrsid-image=sid +i/x-pcx=pcx +i/x-pict=pic pct +i/x-portable-anymap=pnm +i/x-portable-bitmap=pbm +i/x-portable-graymap=pgm +i/x-portable-pixmap=ppm +i/x-rgb=rgb +i/x-tga=tga +i/x-xbitmap=xbm +i/x-xpixmap=xpm +i/x-xwindowdump=xwd +message/rfc822=eml mime +model/iges=igs iges +model/mesh=msh mesh silo +model/vnd.collada+xml=dae +model/vnd.dwf=dwf +model/vnd.gdl=gdl +model/vnd.gtw=gtw +model/vnd.mts=mts +model/vnd.vtu=vtu +model/vrml=wrl vrml +model/x3d+binary=x3db x3dbz +model/x3d+vrml=x3dv x3dvz +model/x3d+xml=x3d x3dz +t/cache-manifest=appcache +t/calendar=ics ifb +t/css=css +t/csv=csv +t/html=html htm +t/n3=n3 +t/plain=txt text conf def list log in +t/prs.lines.tag=dsc +t/richtext=rtx +t/sgml=sgml sgm +t/tab-separated-values=tsv +t/troff=t tr roff man me ms +t/turtle=ttl +t/uri-list=uri uris urls +t/vcard=vcard +t/vnd.curl=curl +t/vnd.curl.dcurl=dcurl +t/vnd.curl.scurl=scurl +t/vnd.curl.mcurl=mcurl +t/vnd.dvb.subtitle=sub +t/vnd.fly=fly +t/vnd.fmi.flexstor=flx +t/vnd.graphviz=gv +t/vnd.in3d.3dml=3dml +t/vnd.in3d.spot=spot +t/vnd.sun.j2me.app-descriptor=jad +t/vnd.wap.wml=wml +t/vnd.wap.wmlscript=wmls +t/x-asm=s asm +t/x-c=c cc cxx cpp h hh dic +t/x-fortran=f for f77 f90 +t/x-java-source=java +t/x-opml=opml +t/x-pascal=p pas +t/x-nfo=nfo +t/x-setext=etx +t/x-sfv=sfv +t/x-uuencode=uu +t/x-vcalendar=vcs +t/x-vcard=vcf +v/3gpp=3gp +v/3gpp2=3g2 +v/h261=h261 +v/h263=h263 +v/h264=h264 +v/jpeg=jpgv +v/jpm=jpm jpgm +v/mj2=mj2 mjp2 +v/mp4=mp4 mp4v mpg4 +v/mpeg=mpeg mpg mpe m1v m2v +v/ogg=ogv +v/quicktime=qt mov +v/vnd.dece.hd=uvh uvvh +v/vnd.dece.mobile=uvm uvvm +v/vnd.dece.pd=uvp uvvp +v/vnd.dece.sd=uvs uvvs +v/vnd.dece.video=uvv uvvv +v/vnd.dvb.file=dvb +v/vnd.fvt=fvt +v/vnd.mpegurl=mxu m4u +v/vnd.ms-playready.media.pyv=pyv +v/vnd.uvvu.mp4=uvu uvvu +v/vnd.vivo=viv +v/webm=webm +v/x-f4v=f4v +v/x-fli=fli +v/x-flv=flv +v/x-m4v=m4v +v/x-matroska=mkv mk3d mks +v/x-mng=mng +v/x-ms-asf=asf asx +v/x-ms-vob=vob +v/x-ms-wm=wm +v/x-ms-wmv=wmv +v/x-ms-wmx=wmx +v/x-ms-wvx=wvx +v/x-msvideo=avi +v/x-sgi-movie=movie +v/x-smv=smv +x-conference/x-cooltalk=ice \ No newline at end of file diff --git a/calf-file-picker/src/desktopTest/kotlin/jodd/net/MimeTypesTest.kt b/calf-file-picker/src/desktopTest/kotlin/jodd/net/MimeTypesTest.kt new file mode 100644 index 0000000..e6ced49 --- /dev/null +++ b/calf-file-picker/src/desktopTest/kotlin/jodd/net/MimeTypesTest.kt @@ -0,0 +1,77 @@ +// Copyright (c) 2003-present, Jodd Team (http://jodd.org) +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, +// this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. +package jodd.net + +import jodd.net.MimeTypes.findExtensionsByMimeTypes +import jodd.net.MimeTypes.getMimeType +import jodd.net.MimeTypes.lookupMimeType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +internal class MimeTypesTest { + @Test + fun testSimpleMime() { + assertEquals("application/atom+xml", getMimeType("atom")) + assertEquals("audio/x-wav", getMimeType("wav")) + assertEquals("image/jpeg", getMimeType("jpg")) + assertEquals("text/x-asm", getMimeType("asm")) + assertEquals("video/mp4", getMimeType("mp4")) + + assertEquals("image/jpeg", lookupMimeType("jpg")) + assertEquals("application/octet-stream", getMimeType("xxx")) + assertNull(lookupMimeType("xxx")) + } + + @Test + fun testFind() { + val extensionList: List = findExtensionsByMimeTypes("image/jpeg", false) + + assertEquals(3, extensionList.size) + + assertTrue(extensionList.contains("jpe")) + assertTrue(extensionList.contains("jpg")) + assertTrue(extensionList.contains("jpeg")) + + val extensionList2: List = findExtensionsByMimeTypes("image/png", false) + val extensionList3: List = findExtensionsByMimeTypes("image/jpeg, image/png", false) + + assertEquals(extensionList3.size, extensionList2.size + extensionList.size) + } + + @Test + fun testFindWithWildcards() { + val extensionList: List = findExtensionsByMimeTypes("image/*", true) + + assertTrue(extensionList.size > 3) + + assertTrue(extensionList.contains("jpe")) + assertTrue(extensionList.contains("jpg")) + assertTrue(extensionList.contains("jpeg")) + assertTrue(extensionList.contains("bmp")) + assertTrue(extensionList.contains("png")) + } +} \ No newline at end of file diff --git a/calf-file-picker/src/iosMain/kotlin/com.mohamedrejeb.calf.picker/FilePickerLauncher.ios.kt b/calf-file-picker/src/iosMain/kotlin/com.mohamedrejeb.calf.picker/FilePickerLauncher.ios.kt index f70a803..348ca92 100644 --- a/calf-file-picker/src/iosMain/kotlin/com.mohamedrejeb.calf.picker/FilePickerLauncher.ios.kt +++ b/calf-file-picker/src/iosMain/kotlin/com.mohamedrejeb.calf.picker/FilePickerLauncher.ios.kt @@ -157,18 +157,25 @@ private fun createUIDocumentPickerViewController( selectionMode: FilePickerSelectionMode, ): UIDocumentPickerViewController { val contentTypes = - type.value.mapNotNull { mimeType -> - when (mimeType) { - FilePickerFileType.ImageContentType -> UTTypeImage - FilePickerFileType.VideoContentType -> UTTypeVideo - FilePickerFileType.AudioContentType -> UTTypeAudio - FilePickerFileType.DocumentContentType -> UTTypeApplication - FilePickerFileType.TextContentType -> UTTypeText - FilePickerFileType.AllContentType -> UTTypeData - FilePickerFileType.FolderContentType -> UTTypeFolder - else -> UTType.typeWithMIMEType(mimeType) + if (type is FilePickerFileType.Extension) + type.value + .mapNotNull { extension -> + UTType.typeWithFilenameExtension(extension) + } + .ifEmpty { listOf(UTTypeData) } + else + type.value.mapNotNull { mimeType -> + when (mimeType) { + FilePickerFileType.ImageContentType -> UTTypeImage + FilePickerFileType.VideoContentType -> UTTypeVideo + FilePickerFileType.AudioContentType -> UTTypeAudio + FilePickerFileType.DocumentContentType -> UTTypeApplication + FilePickerFileType.TextContentType -> UTTypeText + FilePickerFileType.AllContentType -> UTTypeData + FilePickerFileType.FolderContentType -> UTTypeFolder + else -> UTType.typeWithMIMEType(mimeType) + } } - } val pickerController = UIDocumentPickerViewController( diff --git a/calf-file-picker/src/jsMain/kotlin/com.mohamedrejeb.calf.picker/FilePickerLauncher.js.kt b/calf-file-picker/src/jsMain/kotlin/com.mohamedrejeb.calf.picker/FilePickerLauncher.js.kt index 9b07e68..8312e18 100644 --- a/calf-file-picker/src/jsMain/kotlin/com.mohamedrejeb.calf.picker/FilePickerLauncher.js.kt +++ b/calf-file-picker/src/jsMain/kotlin/com.mohamedrejeb.calf.picker/FilePickerLauncher.js.kt @@ -8,7 +8,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import com.mohamedrejeb.calf.io.KmpFile import kotlinx.browser.document +import org.w3c.dom.HTMLInputElement +import org.w3c.dom.asList import org.w3c.files.File +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException @Composable actual fun rememberFilePickerLauncher( @@ -23,27 +27,40 @@ actual fun rememberFilePickerLauncher( type = type, selectionMode = selectionMode, onLaunch = { - val fileInputElement = document.createElement("input") - fileInputElement.setAttribute("style", "display='none'") - fileInputElement.setAttribute("type", "file") - fileInputElement.setAttribute("name", "file") + val fileInputElement = document.createElement("input") as HTMLInputElement - fileInputElement.setAttribute("accept", type.value.joinToString(", ")) + with(fileInputElement) { + style.display = "none" + this.type = "file" + name = "file" - if (selectionMode == FilePickerSelectionMode.Multiple) - fileInputElement.setAttribute("multiple", "true") - else - fileInputElement.removeAttribute("multiple") + accept = + if (type is FilePickerFileType.Extension) + type.value.joinToString(", ") { ".$it" } + else + type.value.joinToString(", ") - fileInputElement.addEventListener("change", { - val files: Array = fileInputElement.asDynamic().files - onResult(files.map { KmpFile(it) }) - fileDialogVisible = false - }) + multiple = selectionMode == FilePickerSelectionMode.Multiple - js("fileInputElement.click()") + onchange = { event -> + try { + // Get the selected files + val files = event.target + ?.unsafeCast() + ?.files + ?.asList() + .orEmpty() - Unit + // Return the result + onResult(files.map { KmpFile(it) }) + fileDialogVisible = false + } catch (e: Throwable) { + e.printStackTrace() + } + } + + click() + } }, ) } diff --git a/calf-file-picker/src/wasmJsMain/kotlin/com.mohamedrejeb.calf/picker/FilePickerLauncher.wasmJs.kt b/calf-file-picker/src/wasmJsMain/kotlin/com.mohamedrejeb.calf/picker/FilePickerLauncher.wasmJs.kt index 2bd513f..ac4c9a3 100644 --- a/calf-file-picker/src/wasmJsMain/kotlin/com.mohamedrejeb.calf/picker/FilePickerLauncher.wasmJs.kt +++ b/calf-file-picker/src/wasmJsMain/kotlin/com.mohamedrejeb.calf/picker/FilePickerLauncher.wasmJs.kt @@ -8,8 +8,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import com.mohamedrejeb.calf.io.KmpFile import kotlinx.browser.document -import org.w3c.dom.Element -import org.w3c.files.File +import org.w3c.dom.HTMLInputElement +import org.w3c.dom.asList @Composable actual fun rememberFilePickerLauncher( @@ -24,29 +24,40 @@ actual fun rememberFilePickerLauncher( type = type, selectionMode = selectionMode, onLaunch = { - val fileInputElement = document.createElement("input") - fileInputElement.setAttribute("style", "display='none'") - fileInputElement.setAttribute("type", "file") - fileInputElement.setAttribute("name", "file") + val fileInputElement = document.createElement("input") as HTMLInputElement - fileInputElement.setAttribute("accept", type.value.joinToString(", ")) + with(fileInputElement) { + style.display = "none" + this.type = "file" + name = "file" - if (selectionMode == FilePickerSelectionMode.Multiple) - fileInputElement.setAttribute("multiple", "true") - else - fileInputElement.removeAttribute("multiple") + accept = + if (type is FilePickerFileType.Extension) + type.value.joinToString(", ") { ".$it" } + else + type.value.joinToString(", ") - fileInputElement.addEventListener("change") { - val filesCount = getInputElementFilesCount(fileInputElement) - val files = - List(filesCount) { index -> - getInputElementFile(fileInputElement, index) + multiple = selectionMode == FilePickerSelectionMode.Multiple + + onchange = { event -> + try { + // Get the selected files + val files = event.target + ?.unsafeCast() + ?.files + ?.asList() + .orEmpty() + + // Return the result + onResult(files.map { KmpFile(it) }) + fileDialogVisible = false + } catch (e: Throwable) { + e.printStackTrace() } - onResult(files.map { KmpFile(it) }) - fileDialogVisible = false - } + } - openFileDialog(fileInputElement) + click() + } }, ) } @@ -61,12 +72,3 @@ actual class FilePickerLauncher actual constructor( onLaunch() } } - -private fun getInputElementFilesCount(element: Element): Int = js("element.files.length") - -private fun getInputElementFile( - element: Element, - index: Int, -): File = js("element.files[index]") - -private fun openFileDialog(element: Element): Unit = js("element.click()") diff --git a/docs/filepicker.md b/docs/filepicker.md index c8b7f0a..7e54ce2 100644 --- a/docs/filepicker.md +++ b/docs/filepicker.md @@ -62,17 +62,22 @@ Button( * `FilePickerFileType.Document` - Allows you to pick documents only * `FilePickerFileType.Text` - Allows you to pick text files only * `FilePickerFileType.Pdf` - Allows you to pick PDF files only -* `FilePickerFileType.Presentation` - Allows you to pick presentation files only -* `FilePickerFileType.Spreadsheet` - Allows you to pick spreadsheet files only -* `FilePickerFileType.Word` - Allows you to pick compressed word only * `FilePickerFileType.All` - Allows you to pick all types of files * `FilePickerFileType.Folder` - Allows you to pick folders -You can also specify the file types you want to pick by using the `FilePickerFileType.Custom` type: +You can filter files by custom mime types using `FilePickerFileType.Custom`. ```kotlin val type = FilePickerFileType.Custom( - "text/plain" + listOf("text/plain") +) +``` + +You can also filter files by custom extensions using `FilePickerFileType.Extension`. + +```kotlin +val type = FilePickerFileType.Extension( + listOf("txt") ) ``` @@ -135,7 +140,8 @@ val isDirectory = file.isDirectory(context) #### Platform-specific APIs -KmpFile is a wrapper around platform-specific APIs, so you can use the platform-specific APIs to read the file: +KmpFile is a wrapper around platform-specific APIs, +you can access the native APIs for each platform using the following properties: ##### Android ```kotlin diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de4939d..0e72269 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ compose = "1.6.11" kotlinx-serialization-json = "1.6.3" nexus-publish = "2.0.0" documentfile = "1.0.1" +jna = "5.14.0" coil = "3.0.0-alpha06" conventionPlugin = "0.1.0" @@ -27,6 +28,7 @@ gradlePlugin-jetbrainsCompose = { module = "org.jetbrains.compose:compose-gradle gradlePlugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } lifecycle-extensions = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "android-lifecycle" } play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "play-services-location" } @@ -41,8 +43,10 @@ coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } # Android documentfile = { module = "androidx.documentfile:documentfile", version.ref = "documentfile" } +# Desktop +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } + # For sample -activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } calf-ui = { module = "com.mohamedrejeb.calf:calf-ui", version.ref = "calf" } calf-file-picker = { module = "com.mohamedrejeb.calf:calf-file-picker", version.ref = "calf" } diff --git a/sample/common/src/commonMain/kotlin/com.mohamedrejeb.calf.sample/screens/FilePickerScreen.kt b/sample/common/src/commonMain/kotlin/com.mohamedrejeb.calf.sample/screens/FilePickerScreen.kt index 7360488..e43a02a 100644 --- a/sample/common/src/commonMain/kotlin/com.mohamedrejeb.calf.sample/screens/FilePickerScreen.kt +++ b/sample/common/src/commonMain/kotlin/com.mohamedrejeb.calf.sample/screens/FilePickerScreen.kt @@ -63,6 +63,15 @@ fun FilePickerScreen(navigateBack: () -> Unit) { }, ) + val directoryPickerLauncher = + rememberFilePickerLauncher( + type = FilePickerFileType.Folder, + selectionMode = FilePickerSelectionMode.Single, + onResult = { files -> + fileNames = files.map { it.getName(context).orEmpty() } + }, + ) + Column( modifier = Modifier @@ -116,6 +125,15 @@ fun FilePickerScreen(navigateBack: () -> Unit) { Text("Pick Files") } + Button( + onClick = { + directoryPickerLauncher.launch() + }, + modifier = Modifier.padding(16.dp), + ) { + Text("Pick Directory") + } + Text( text = "Files picked:", style = MaterialTheme.typography.titleLarge,