diff --git a/docs/dialogs/open-file.mdx b/docs/dialogs/open-file.mdx index 7d14fcb0..817a3d9a 100644 --- a/docs/dialogs/open-file.mdx +++ b/docs/dialogs/open-file.mdx @@ -38,14 +38,14 @@ FileKit.openFileWithDefaultApplication(file) ## Platform notes -Failures are not wrapped in `FileKitDialogException`. Each platform reports them its own way: +Failures are reported differently on each platform. Only the JVM wraps some of them in `FileKitDialogException`: | Platform | Behavior | | --- | --- | | Android | Sends an `ACTION_VIEW` intent with the MIME type of the file extension. Throws `ActivityNotFoundException` when no app handles that type, and `IllegalArgumentException` for a file outside the directories its FileProvider covers. | | iOS | Calls `openURL`. If that fails, shows the "Open in" menu (`UIDocumentInteractionController`) so the user can pick an app. Reports no error. | | macOS | Uses `NSWorkspace`. Reports no error. | -| JVM | Uses `java.awt.Desktop.open` without checking `Desktop.isDesktopSupported()`. Throws on headless setups (`HeadlessException`), when the action is unsupported (`UnsupportedOperationException`), or when no app is associated (`IOException`). | +| JVM | Uses `java.awt.Desktop.open` when the desktop supports opening files. Otherwise (headless setups, Linux without GNOME libraries), on Linux it falls back to `xdg-open` and waits up to half a second for it to report an error. Throws `FileKitDialogException` when neither works: `Desktop` cannot open files and `xdg-open` is missing, cannot start, or exits with an error code. Errors from `Desktop.open` itself are not wrapped, such as `IOException` when no app is associated. | | Linux native | Starts `xdg-open` without waiting for it. Throws `FileKitException` only if the process cannot start. | | Windows native | Calls `ShellExecuteW` with the `open` verb. Reports no error. | | Web | Not available. | diff --git a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.kt b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.kt index 903b5972..92152aac 100644 --- a/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.kt +++ b/filekit-core/src/commonMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.kt @@ -5,6 +5,16 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +/** + * kotlinx.serialization serializer for [PlatformFile], encoded as its path string. + * + * Serialization keeps the path, not access rights: a file picked outside the app (an Android + * `content://` file, a security-scoped file on Apple platforms) may not be readable once restored. + * Use bookmark data for persistent access. + * + * Not supported on web targets (JS and Wasm): [serialize] and [deserialize] throw an + * [IllegalStateException] there. + */ public expect object PlatformFileSerializer : KSerializer { override val descriptor: SerialDescriptor diff --git a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.wasmJs.kt b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.web.kt similarity index 67% rename from filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.wasmJs.kt rename to filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.web.kt index a529fdde..5b45cd14 100644 --- a/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.wasmJs.kt +++ b/filekit-core/src/webMain/kotlin/io/github/vinceglb/filekit/PlatformFileSerializer.web.kt @@ -8,8 +8,15 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder private const val UNSUPPORTED_MESSAGE = - "PlatformFile serialization is not supported on Wasm JS targets" + "PlatformFile serialization is not supported on web targets (JS and Wasm)" +/** + * Web (JS and Wasm) implementation of [PlatformFileSerializer]. + * + * A web [PlatformFile] wraps a browser `File` object that has no path to persist, so + * [serialize] and [deserialize] always throw an [IllegalStateException]. The serializer + * exists so that `@Serializable` classes holding a [PlatformFile] still compile in shared code. + */ public actual object PlatformFileSerializer : KSerializer { actual override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( serialName = "io.github.vinceglb.filekit.PlatformFile", diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.jvm.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.jvm.kt index c357f8a9..3143a568 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.jvm.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKit.jvm.kt @@ -6,7 +6,6 @@ import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext -import java.awt.Desktop internal actual suspend fun FileKit.platformOpenFilePicker( type: FileKitType, @@ -97,12 +96,19 @@ internal actual suspend fun FileKit.platformOpenFileSaver( /** * Opens a file with the default application associated with its file type. * + * Uses `java.awt.Desktop` when it supports opening files. Otherwise, on Linux, falls back to + * `xdg-open` and waits half a second at most for it to report an error. + * * @param file The file to open. * @param openFileSettings Platform-specific settings for opening the file. + * @throws FileKitDialogException When `java.awt.Desktop` cannot open files (for example in a headless + * environment) and the `xdg-open` fallback is unavailable, cannot start, or exits with an error. + * Errors raised by `java.awt.Desktop.open` itself, such as an [java.io.IOException] when no application + * is associated with the file, are not wrapped. */ public actual fun FileKit.openFileWithDefaultApplication( file: PlatformFile, openFileSettings: FileKitOpenFileSettings, ) { - Desktop.getDesktop()?.open(file.file) + JvmDefaultApplicationOpener.Default.open(file.file) } diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/JvmDefaultApplicationOpener.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/JvmDefaultApplicationOpener.kt new file mode 100644 index 00000000..a4b55b28 --- /dev/null +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/JvmDefaultApplicationOpener.kt @@ -0,0 +1,83 @@ +package io.github.vinceglb.filekit.dialogs + +import io.github.vinceglb.filekit.utils.Platform +import io.github.vinceglb.filekit.utils.PlatformUtil +import java.awt.Desktop +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit + +// Long enough to catch xdg-open failing fast (missing file or tool), short enough for a click handler. +internal const val XDG_OPEN_EXIT_TIMEOUT_MILLIS: Long = 500 + +/** + * Opens files with the default application on the JVM. + * + * Uses `java.awt.Desktop` when it can open files. Otherwise, on Linux, starts `xdg-open` and waits + * at most [XDG_OPEN_EXIT_TIMEOUT_MILLIS] for it to report an error. Throws [FileKitDialogException] when neither works. + */ +internal class JvmDefaultApplicationOpener( + private val isDesktopOpenSupported: () -> Boolean, + private val desktopOpen: (File) -> Unit, + private val isLinux: Boolean, + private val startProcess: (List) -> Process, + private val exitTimeoutMillis: Long = XDG_OPEN_EXIT_TIMEOUT_MILLIS, +) { + fun open(file: File) { + when { + isDesktopOpenSupported() -> desktopOpen(file) + + isLinux -> openWithXdgOpen(file) + + else -> throw FileKitDialogException( + "Could not open the file with the default application: " + + "java.awt.Desktop cannot open files in this environment.", + ) + } + } + + private fun openWithXdgOpen(file: File) { + val process = try { + // An absolute path keeps xdg-open from reading the argument as an option or a URI scheme. + startProcess(listOf("xdg-open", file.absolutePath)) + } catch (e: IOException) { + throw FileKitDialogException( + "Could not open the file with the default application: " + + "java.awt.Desktop cannot open files and xdg-open could not be started.", + e, + ) + } + + val exited = try { + process.waitFor(exitTimeoutMillis, TimeUnit.MILLISECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return + } + + // Some xdg-open fallbacks keep running until the viewer closes: treat that as success. + if (exited && process.exitValue() != 0) { + throw FileKitDialogException( + "Could not open the file with the default application: " + + "xdg-open exited with code ${process.exitValue()}.", + ) + } + } + + companion object { + val Default: JvmDefaultApplicationOpener = JvmDefaultApplicationOpener( + isDesktopOpenSupported = { + Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN) + }, + desktopOpen = { Desktop.getDesktop().open(it) }, + isLinux = PlatformUtil.current == Platform.Linux, + startProcess = { command -> + ProcessBuilder(command) + .redirectInput(ProcessBuilder.Redirect.from(File("/dev/null"))) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + }, + ) + } +} diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/JvmDefaultApplicationOpenerTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/JvmDefaultApplicationOpenerTest.kt new file mode 100644 index 00000000..79a3d9ee --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/JvmDefaultApplicationOpenerTest.kt @@ -0,0 +1,148 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs + +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class JvmDefaultApplicationOpenerTest { + private val file = File("report.pdf") + + @Test + fun JvmDefaultApplicationOpener_desktopSupported_usesDesktopOnly() { + val opened = mutableListOf() + val commands = mutableListOf>() + val opener = opener( + desktopSupported = true, + isLinux = true, + opened = opened, + commands = commands, + ) + + opener.open(file) + + assertEquals(listOf(file), opened) + assertTrue(commands.isEmpty()) + } + + @Test + fun JvmDefaultApplicationOpener_desktopError_isNotWrapped() { + val opener = JvmDefaultApplicationOpener( + isDesktopOpenSupported = { true }, + desktopOpen = { throw IOException("No application") }, + isLinux = true, + startProcess = { error("xdg-open must not run") }, + ) + + assertFailsWith { opener.open(file) } + } + + @Test + fun JvmDefaultApplicationOpener_desktopUnsupportedOnLinux_runsXdgOpenWithAbsolutePath() { + val commands = mutableListOf>() + val opener = opener( + isLinux = true, + commands = commands, + process = FakeProcess(exitCode = 0), + ) + + opener.open(file) + + assertEquals(listOf(listOf("xdg-open", file.absolutePath)), commands) + } + + @Test + fun JvmDefaultApplicationOpener_xdgOpenStillRunning_returnsWithoutWaitingForever() { + val process = FakeProcess(exitCode = null) + val opener = opener(isLinux = true, process = process) + + opener.open(file) + + assertEquals(TIMEOUT_MILLIS, process.waitedMillis) + } + + @Test + fun JvmDefaultApplicationOpener_xdgOpenNonZeroExit_throwsDialogException() { + val opener = opener(isLinux = true, process = FakeProcess(exitCode = 3)) + + val exception = assertFailsWith { opener.open(file) } + + assertTrue(exception.message!!.contains("xdg-open exited with code 3")) + } + + @Test + fun JvmDefaultApplicationOpener_xdgOpenMissing_throwsDialogExceptionWithCause() { + val opener = JvmDefaultApplicationOpener( + isDesktopOpenSupported = { false }, + desktopOpen = { error("Desktop must not be used") }, + isLinux = true, + startProcess = { throw IOException("Cannot run program \"xdg-open\"") }, + ) + + val exception = assertFailsWith { opener.open(file) } + + assertIs(exception.cause) + } + + @Test + fun JvmDefaultApplicationOpener_desktopUnsupportedOutsideLinux_throwsDialogException() { + val commands = mutableListOf>() + val opener = opener(isLinux = false, commands = commands) + + assertFailsWith { opener.open(file) } + assertTrue(commands.isEmpty()) + } + + private fun opener( + desktopSupported: Boolean = false, + isLinux: Boolean, + opened: MutableList = mutableListOf(), + commands: MutableList> = mutableListOf(), + process: Process = FakeProcess(exitCode = 0), + ) = JvmDefaultApplicationOpener( + isDesktopOpenSupported = { desktopSupported }, + desktopOpen = { opened += it }, + isLinux = isLinux, + startProcess = { command -> + commands += command + process + }, + exitTimeoutMillis = TIMEOUT_MILLIS, + ) + + /** A process that has exited with [exitCode], or is still running when [exitCode] is null. */ + private class FakeProcess( + private val exitCode: Int?, + ) : Process() { + var waitedMillis: Long? = null + + override fun waitFor(timeout: Long, unit: TimeUnit): Boolean { + waitedMillis = unit.toMillis(timeout) + return exitCode != null + } + + override fun waitFor(): Int = exitCode ?: error("Unbounded wait") + + override fun exitValue(): Int = exitCode ?: throw IllegalThreadStateException() + + override fun getOutputStream(): OutputStream = OutputStream.nullOutputStream() + + override fun getInputStream(): InputStream = InputStream.nullInputStream() + + override fun getErrorStream(): InputStream = InputStream.nullInputStream() + + override fun destroy() = Unit + } + + private companion object { + const val TIMEOUT_MILLIS = 50L + } +} diff --git a/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitFileSaverWithoutBytesException.kt b/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitFileSaverWithoutBytesException.kt deleted file mode 100644 index a0aa1b1c..00000000 --- a/filekit-dialogs/src/webMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitFileSaverWithoutBytesException.kt +++ /dev/null @@ -1,7 +0,0 @@ -package io.github.vinceglb.filekit.dialogs - -public class FileKitFileSaverWithoutBytesException : - IllegalArgumentException( - "Bytes must not be null on Web platform. Use isSaveFileWithoutBytesSupported() " + - "to check if the platform supports saving files without bytes.", - )