diff --git a/.gitignore b/.gitignore index 96bbc511..20301246 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ captures !*.xcworkspace/contents.xcworkspacedata **/xcshareddata/WorkspaceSettings.xcsettings node_modules/ +.scratch/ #So we don't accidentally commit our private keys *.gpg diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogExecutor.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogExecutor.kt new file mode 100644 index 00000000..804beec6 --- /dev/null +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogExecutor.kt @@ -0,0 +1,60 @@ +package io.github.vinceglb.filekit.dialogs.platform.windows + +import com.sun.jna.platform.win32.Ole32 +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.withContext +import java.util.concurrent.Executors + +internal interface WindowsComRuntime { + fun initializeSta(): Int + + fun uninitialize() +} + +internal object JnaWindowsComRuntime : WindowsComRuntime { + override fun initializeSta(): Int = Ole32.INSTANCE + .CoInitializeEx( + null, + Ole32.COINIT_APARTMENTTHREADED or Ole32.COINIT_DISABLE_OLE1DDE, + ).toInt() + + override fun uninitialize() { + Ole32.INSTANCE.CoUninitialize() + } +} + +internal class WindowsDialogExecutor( + private val comRuntime: WindowsComRuntime, +) : AutoCloseable { + private val dispatcher = Executors + .newSingleThreadExecutor { runnable -> + Thread(runnable, THREAD_NAME).apply { + isDaemon = true + } + }.asCoroutineDispatcher() + + suspend fun execute(block: () -> T): T = withContext(dispatcher) { + val initializationResult = comRuntime.initializeSta() + if (initializationResult != S_OK && initializationResult != S_FALSE) { + throw RuntimeException( + "CoInitializeEx failed with HRESULT 0x${initializationResult.toUInt().toString(16)}", + ) + } + + try { + block() + } finally { + comRuntime.uninitialize() + } + } + + override fun close() { + dispatcher.close() + } + + private companion object { + const val THREAD_NAME = "FileKit-Windows-Dialog" + const val S_OK = 0 + const val S_FALSE = 1 + } +} diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt index 71306f61..ea40e2a9 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt @@ -2,11 +2,9 @@ package io.github.vinceglb.filekit.dialogs.platform.windows import com.sun.jna.Native import com.sun.jna.WString -import com.sun.jna.platform.win32.COM.COMUtils import com.sun.jna.platform.win32.COM.COMUtils.FAILED import com.sun.jna.platform.win32.Guid import com.sun.jna.platform.win32.Ole32 -import com.sun.jna.platform.win32.Ole32.COINIT_APARTMENTTHREADED import com.sun.jna.platform.win32.WTypes import com.sun.jna.platform.win32.Win32Exception import com.sun.jna.platform.win32.WinDef @@ -35,12 +33,12 @@ import io.github.vinceglb.filekit.dialogs.platform.windows.jna.ShellItem import io.github.vinceglb.filekit.dialogs.platform.windows.jna.ShellItemArray import io.github.vinceglb.filekit.dialogs.platform.windows.util.GuidFixed import io.github.vinceglb.filekit.path -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import java.awt.Window import java.io.File -internal class WindowsFilePicker : PlatformFilePicker { +internal class WindowsFilePicker( + private val dialogExecutor: WindowsDialogExecutor = WindowsDialogExecutor(JnaWindowsComRuntime), +) : PlatformFilePicker { override suspend fun openFilePicker( fileExtensions: Set?, directory: PlatformFile?, @@ -152,12 +150,9 @@ internal class WindowsFilePicker : PlatformFilePicker { private suspend fun useFileDialog( type: FileDialogType, block: (FD) -> T, - ): T = withContext(Dispatchers.IO) { + ): T = dialogExecutor.execute { var fileDialog: FD? = null try { - // Initialize COM - initCom() - // Create FileOpenDialog val pbrFileDialog = PointerByReference() fileDialog = Ole32.INSTANCE @@ -174,7 +169,6 @@ internal class WindowsFilePicker : PlatformFilePicker { block(fileDialog) } finally { fileDialog?.Release() - Ole32.INSTANCE.CoUninitialize() } } @@ -199,19 +193,6 @@ internal class WindowsFilePicker : PlatformFilePicker { } } - private fun initCom() { - Ole32.INSTANCE - .CoInitializeEx( - null, - COINIT_APARTMENTTHREADED or Ole32.COINIT_DISABLE_OLE1DDE, - ).verify("CoInitializeEx failed") - - val isInit = COMUtils.comIsInitialized() - if (!isInit) { - throw RuntimeException("COM initialization failed") - } - } - private fun FileDialog.setDefaultPath(defaultPath: PlatformFile) { val pbrFolder = PointerByReference() val resultFolder = Shell32.INSTANCE.SHCreateItemFromParsingName( diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogExecutorIntegrationTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogExecutorIntegrationTest.kt new file mode 100644 index 00000000..e1f11856 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogExecutorIntegrationTest.kt @@ -0,0 +1,75 @@ +package io.github.vinceglb.filekit.dialogs.platform.windows + +import com.sun.jna.platform.win32.Ole32 +import com.sun.jna.platform.win32.WTypes +import com.sun.jna.ptr.PointerByReference +import io.github.vinceglb.filekit.dialogs.platform.windows.jna.FileOpenDialog +import io.github.vinceglb.filekit.dialogs.platform.windows.jna.IFileOpenDialog +import io.github.vinceglb.filekit.utils.Platform +import io.github.vinceglb.filekit.utils.PlatformUtil +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.util.concurrent.Executors +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull + +@Suppress("ktlint:standard:function-naming", "FunctionName") +class WindowsDialogExecutorIntegrationTest { + @Test + fun WindowsDialogExecutor_executeFromMtaCaller_createsFileDialogOnOwnedStaThread() = runBlocking { + if (PlatformUtil.current != Platform.Windows) return@runBlocking + + val callerDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + try { + withContext(callerDispatcher) { + val callerThread = Thread.currentThread() + val callerInitialization = Ole32.INSTANCE + .CoInitializeEx(null, Ole32.COINIT_MULTITHREADED) + .toInt() + assertEquals(S_OK, callerInitialization) + + try { + val dialogExecutor = WindowsDialogExecutor(JnaWindowsComRuntime) + try { + val dialogThread = dialogExecutor.execute { + val dialogPointer = PointerByReference() + val creationResult = Ole32.INSTANCE + .CoCreateInstance( + IFileOpenDialog.CLSID_FILEOPENDIALOG, + null, + WTypes.CLSCTX_ALL, + IFileOpenDialog.IID_IFILEOPENDIALOG, + dialogPointer, + ).toInt() + + assertEquals(S_OK, creationResult) + val pointer = assertNotNull(dialogPointer.value) + val fileDialog = FileOpenDialog(pointer) + try { + Thread.currentThread() + } finally { + fileDialog.Release() + } + } + + assertNotEquals(callerThread, dialogThread) + assertEquals("FileKit-Windows-Dialog", dialogThread.name) + } finally { + dialogExecutor.close() + } + } finally { + Ole32.INSTANCE.CoUninitialize() + } + } + } finally { + callerDispatcher.close() + } + } + + private companion object { + const val S_OK = 0 + } +} diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogExecutorTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogExecutorTest.kt new file mode 100644 index 00000000..9d149266 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogExecutorTest.kt @@ -0,0 +1,212 @@ +package io.github.vinceglb.filekit.dialogs.platform.windows + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import java.util.concurrent.CountDownLatch +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@Suppress("ktlint:standard:function-naming", "FunctionName") +class WindowsDialogExecutorTest { + @Test + fun WindowsDialogExecutor_executeAfterSuccessfulInitialization_balancesCom() = runBlocking { + val comRuntime = FakeWindowsComRuntime(initializationResult = S_OK) + val executor = WindowsDialogExecutor(comRuntime) + + try { + val result = executor.execute { "selected.txt" } + + assertEquals("selected.txt", result) + assertEquals(1, comRuntime.initializeCalls) + assertEquals(1, comRuntime.uninitializeCalls) + } finally { + executor.close() + } + } + + @Test + fun WindowsDialogExecutor_executeWhenComWasAlreadyInitialized_balancesCom() = runBlocking { + val comRuntime = FakeWindowsComRuntime(initializationResult = S_FALSE) + val executor = WindowsDialogExecutor(comRuntime) + + try { + val result = executor.execute { "selected.txt" } + + assertEquals("selected.txt", result) + assertEquals(1, comRuntime.initializeCalls) + assertEquals(1, comRuntime.uninitializeCalls) + } finally { + executor.close() + } + } + + @Test + fun WindowsDialogExecutor_execute_usesNamedDaemonThread() = runBlocking { + val comRuntime = FakeWindowsComRuntime(initializationResult = S_OK) + val executor = WindowsDialogExecutor(comRuntime) + + try { + val worker = executor.execute { Thread.currentThread() } + + assertEquals("FileKit-Windows-Dialog", worker.name) + assertTrue(worker.isDaemon) + } finally { + executor.close() + } + } + + @Test + fun WindowsDialogExecutor_executeWhenApartmentModeChanged_doesNotRunOrUninitialize() = runBlocking { + val comRuntime = FakeWindowsComRuntime(initializationResult = RPC_E_CHANGED_MODE) + val executor = WindowsDialogExecutor(comRuntime) + var operationCalled = false + + try { + val exception = assertFailsWith { + executor.execute { + operationCalled = true + } + } + + assertFalse(operationCalled) + assertEquals(1, comRuntime.initializeCalls) + assertEquals(0, comRuntime.uninitializeCalls) + assertEquals("CoInitializeEx failed with HRESULT 0x80010106", exception.message) + } finally { + executor.close() + } + } + + @Test + fun WindowsDialogExecutor_executeWhenInitializationFails_doesNotRunOrUninitialize() = runBlocking { + val comRuntime = FakeWindowsComRuntime(initializationResult = E_OUTOFMEMORY) + val executor = WindowsDialogExecutor(comRuntime) + var operationCalled = false + + try { + val exception = assertFailsWith { + executor.execute { + operationCalled = true + } + } + + assertFalse(operationCalled) + assertEquals(1, comRuntime.initializeCalls) + assertEquals(0, comRuntime.uninitializeCalls) + assertEquals("CoInitializeEx failed with HRESULT 0x8007000e", exception.message) + } finally { + executor.close() + } + } + + @Test + fun WindowsDialogExecutor_executeWhenOperationFails_balancesCom() = runBlocking { + val comRuntime = FakeWindowsComRuntime(initializationResult = S_OK) + val executor = WindowsDialogExecutor(comRuntime) + + try { + assertFailsWith { + executor.execute { throw ExpectedException() } + } + + assertEquals(1, comRuntime.initializeCalls) + assertEquals(1, comRuntime.uninitializeCalls) + } finally { + executor.close() + } + } + + @Test + fun WindowsDialogExecutor_executeAfterClose_rejectsOperation() = runBlocking { + val comRuntime = FakeWindowsComRuntime(initializationResult = S_OK) + val executor = WindowsDialogExecutor(comRuntime) + executor.close() + + val exception = assertFailsWith { + executor.execute { "selected.txt" } + } + + var cause: Throwable? = exception + while (cause != null && cause !is RejectedExecutionException) { + cause = cause.cause + } + assertTrue(cause is RejectedExecutionException) + + assertEquals(0, comRuntime.initializeCalls) + assertEquals(0, comRuntime.uninitializeCalls) + } + + @Test + fun WindowsDialogExecutor_executeConcurrently_serializesOnOwnedThread() = runBlocking { + val comRuntime = FakeWindowsComRuntime(initializationResult = S_OK) + val executor = WindowsDialogExecutor(comRuntime) + val firstStarted = CountDownLatch(1) + val releaseFirst = CountDownLatch(1) + val secondStarted = CountDownLatch(1) + + try { + val first = async(Dispatchers.Default) { + executor.execute { + firstStarted.countDown() + assertTrue(releaseFirst.await(5, TimeUnit.SECONDS)) + Thread.currentThread() + } + } + assertTrue(firstStarted.await(5, TimeUnit.SECONDS)) + + val second = async(Dispatchers.Default, start = CoroutineStart.UNDISPATCHED) { + executor.execute { + secondStarted.countDown() + Thread.currentThread() + } + } + assertFalse(secondStarted.await(100, TimeUnit.MILLISECONDS)) + + releaseFirst.countDown() + val firstThread = first.await() + val secondThread = second.await() + + assertEquals(firstThread, secondThread) + assertTrue(secondStarted.await(5, TimeUnit.SECONDS)) + } finally { + releaseFirst.countDown() + executor.close() + } + } + + private class FakeWindowsComRuntime( + private val initializationResult: Int, + ) : WindowsComRuntime { + var initializeCalls = 0 + private set + + var uninitializeCalls = 0 + private set + + override fun initializeSta(): Int { + initializeCalls += 1 + return initializationResult + } + + override fun uninitialize() { + uninitializeCalls += 1 + } + } + + private class ExpectedException : RuntimeException() + + private companion object { + const val S_OK = 0 + const val S_FALSE = 1 + val RPC_E_CHANGED_MODE = 0x80010106u.toInt() + val E_OUTOFMEMORY = 0x8007000Eu.toInt() + } +}