From 6aee0db8217777c62ea46f0f22368906ce2a7de1 Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Fri, 25 Sep 2026 11:52:35 +0300 Subject: [PATCH 1/3] feat(application): parent FileKit dialogs to a Nucleus window NucleusWindow.withFileKitDialogSettings { } fills FileKitDialogSettings.parent from the window's platform identity: HWND on Windows, x11: on X11, an xdg_foreign export held for the dialog's duration on Wayland. macOS stays unparented (FileKit 0.15 only accepts an AWT parent there). --- .../api/nucleus-application.api | 5 ++ nucleus-application/build.gradle.kts | 5 +- .../application/FileKitDialogs.kt | 76 +++++++++++++++++++ .../application/FileKitDialogsTest.kt | 57 ++++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt create mode 100644 nucleus-application/src/test/kotlin/dev/nucleusframework/application/FileKitDialogsTest.kt diff --git a/nucleus-application/api/nucleus-application.api b/nucleus-application/api/nucleus-application.api index f7c4d43e2..09b228dca 100644 --- a/nucleus-application/api/nucleus-application.api +++ b/nucleus-application/api/nucleus-application.api @@ -53,6 +53,11 @@ public final class dev/nucleusframework/application/DefaultNucleusWindowHost : d public fun Window-rOktWo0 (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V } +public final class dev/nucleusframework/application/FileKitDialogsKt { + public static final fun withFileKitDialogSettings (Ldev/nucleusframework/application/NucleusWindow;Lio/github/vinceglb/filekit/dialogs/FileKitDialogSettings;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun withFileKitDialogSettings$default (Ldev/nucleusframework/application/NucleusWindow;Lio/github/vinceglb/filekit/dialogs/FileKitDialogSettings;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + public final class dev/nucleusframework/application/NucleusApplicationKt { public static final fun nucleusApplication ([Ljava/lang/String;ZLjava/util/Locale;ZZZLkotlin/jvm/functions/Function3;)V public static synthetic fun nucleusApplication$default ([Ljava/lang/String;ZLjava/util/Locale;ZZZLkotlin/jvm/functions/Function3;ILjava/lang/Object;)V diff --git a/nucleus-application/build.gradle.kts b/nucleus-application/build.gradle.kts index 676d97b46..ceca76ab7 100644 --- a/nucleus-application/build.gradle.kts +++ b/nucleus-application/build.gradle.kts @@ -40,11 +40,14 @@ dependencies { api(project(":decorated-window-tao")) // compileOnly: nucleusApplication initializes FileKit only when the app - // ships it (see FileKitIntegration.kt); never forced on consumers. + // ships it (see FileKitIntegration.kt), and withFileKitDialogSettings is + // only callable by an app that has filekit-dialogs; never forced on consumers. compileOnly(libs.filekit.core) + compileOnly(libs.filekit.dialogs) testImplementation(libs.junit) testImplementation(libs.filekit.core) + testImplementation(libs.filekit.dialogs) testImplementation(compose.desktop.currentOs) testImplementation("org.jetbrains.compose.ui:ui-test-junit4:${libs.versions.compose.get()}") } diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt new file mode 100644 index 000000000..5d677ab61 --- /dev/null +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt @@ -0,0 +1,76 @@ +package dev.nucleusframework.application + +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.XdgPortalParent +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Runs [block] with [settings] parented to this window, so the FileKit dialog it opens is attached + * to the window instead of floating free: + * + * - **Windows**: the window's HWND becomes the dialog's owner. + * - **Linux X11 / XWayland**: the portal gets `x11:`. + * - **Linux Wayland**: the window is exported through `xdg_foreign` for the duration of [block] + * and unexported when it returns, which is the lifetime the portal requires. + * - **macOS**: left unparented — FileKit only accepts an AWT parent there and rejects any other. + * + * A [settings] that already carries a parent is passed through untouched, and so is every + * setting when the window exposes no platform identity (not realized yet, native bridge missing). + * + * ```kotlin + * val window = LocalNucleusWindow.current + * scope.launch { + * val file = window.withFileKitDialogSettings { settings -> + * FileKit.openFilePicker(dialogSettings = settings) + * } + * } + * ``` + * + * Requires `filekit-dialogs` on the app's classpath; `nucleus-application` never ships it. + */ +public suspend fun NucleusWindow.withFileKitDialogSettings( + settings: FileKitDialogSettings = FileKitDialogSettings.createDefault(), + block: suspend (FileKitDialogSettings) -> T, +): T = withDialogParent(settings, { unsafe.taoWindow?.fileKitDialogParent() }, block) + +/** A dialog parent plus whatever keeps it valid (the Wayland export), released after the dialog. */ +internal class BorrowedDialogParent( + val parent: FileKitDialogParent, + private val lease: AutoCloseable? = null, +) : AutoCloseable { + override fun close() { + lease?.close() + } +} + +internal suspend fun withDialogParent( + settings: FileKitDialogSettings, + resolveParent: () -> BorrowedDialogParent?, + block: suspend (FileKitDialogSettings) -> T, +): T { + if (settings.parent != null) return block(settings) + // The Wayland export blocks until the compositor answers, so keep it off the UI thread. + val borrowed = withContext(Dispatchers.IO) { resolveParent() } ?: return block(settings) + return borrowed.use { block(settings.copy(parent = it.parent)) } +} + +private fun TaoWindow.fileKitDialogParent(): BorrowedDialogParent? = + when (Platform.Current) { + Platform.Windows -> { + val hwnd = nativeHandle + if (hwnd == 0L) null else BorrowedDialogParent(FileKitDialogParent.windows(hwnd)) + } + Platform.Linux -> + when (val portalParent = xdgPortalParent()) { + is XdgPortalParent.X11 -> BorrowedDialogParent(FileKitDialogParent.x11(portalParent.xid)) + is XdgPortalParent.Wayland -> + BorrowedDialogParent(FileKitDialogParent.wayland(portalParent.handle), lease = portalParent) + null -> null + } + // FileKit 0.15 accepts only an AWT parent on macOS: an NSWindow would make the picker throw. + else -> null + } diff --git a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/FileKitDialogsTest.kt b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/FileKitDialogsTest.kt new file mode 100644 index 000000000..87a3229be --- /dev/null +++ b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/FileKitDialogsTest.kt @@ -0,0 +1,57 @@ +package dev.nucleusframework.application + +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class FileKitDialogsTest { + private val windowParent = FileKitDialogParent.windows(0x42) + + @Test + fun `parents the settings and releases the lease after the dialog`() = + runBlocking { + var released = false + val settings = FileKitDialogSettings(title = "Open") + val seen = + withDialogParent(settings, { BorrowedDialogParent(windowParent) { released = true } }) { + assertFalse("lease released before the dialog finished", released) + it + } + assertSame(windowParent, seen.parent) + assertEquals("Open", seen.title) + assertTrue(released) + } + + @Test + fun `releases the lease when the dialog throws`() { + var released = false + runCatching { + runBlocking { + withDialogParent(FileKitDialogSettings(), { BorrowedDialogParent(windowParent) { released = true } }) { + error("picker failed") + } + } + } + assertTrue(released) + } + + @Test + fun `keeps a parent the caller already chose`() = + runBlocking { + val chosen = FileKitDialogSettings(parent = FileKitDialogParent.x11(7)) + val seen = withDialogParent(chosen, { error("must not resolve") }) { it } + assertSame(chosen, seen) + } + + @Test + fun `leaves the settings unparented without a platform identity`() = + runBlocking { + val settings = FileKitDialogSettings() + assertSame(settings, withDialogParent(settings, { null }) { it }) + } +} From 0b3c3a4ab03ab14020ac683fc4261a2bfad3cc35 Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Fri, 25 Sep 2026 11:56:52 +0300 Subject: [PATCH 2/3] build: bump FileKit to 0.16.0 --- gradle/libs.versions.toml | 2 +- .../kotlin/dev/nucleusframework/application/FileKitDialogs.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0a94ab1ab..d225e5986 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ composenativetray = "2.1.0" composewebview = "1.0.1" detekt = "2.0.0-alpha.6" downloadTask = "5.7.0" -filekit = "0.15.0" +filekit = "0.16.0" graalvmNative = "1.1.3" # Must match the hot-reload version bundled by the Compose Gradle plugin (which auto-applies # hot-reload to every Compose module): TaoHotReloadBridgeImpl compiles against these artifacts diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt index 5d677ab61..726724c28 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt @@ -71,6 +71,6 @@ private fun TaoWindow.fileKitDialogParent(): BorrowedDialogParent? = BorrowedDialogParent(FileKitDialogParent.wayland(portalParent.handle), lease = portalParent) null -> null } - // FileKit 0.15 accepts only an AWT parent on macOS: an NSWindow would make the picker throw. + // FileKit (0.16) accepts only an AWT parent on macOS: an NSWindow would make the picker throw. else -> null } From d8c3e1a57de3d0702554a5c0bf877c04145e00a2 Mon Sep 17 00:00:00 2001 From: "Elie G." Date: Fri, 25 Sep 2026 12:03:04 +0300 Subject: [PATCH 3/3] docs(application): explain why macOS FileKit dialogs stay unparented --- .../dev/nucleusframework/application/FileKitDialogs.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt index 726724c28..7c932c9c4 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt @@ -16,7 +16,8 @@ import kotlinx.coroutines.withContext * - **Linux X11 / XWayland**: the portal gets `x11:`. * - **Linux Wayland**: the window is exported through `xdg_foreign` for the duration of [block] * and unexported when it returns, which is the lifetime the portal requires. - * - **macOS**: left unparented — FileKit only accepts an AWT parent there and rejects any other. + * - **macOS**: left unparented — FileKit's `runModal` panel is already app-modal (it runs + * `NSApplication.runModal(for:)`), so no other window can take it over. * * A [settings] that already carries a parent is passed through untouched, and so is every * setting when the window exposes no platform identity (not realized yet, native bridge missing). @@ -71,6 +72,6 @@ private fun TaoWindow.fileKitDialogParent(): BorrowedDialogParent? = BorrowedDialogParent(FileKitDialogParent.wayland(portalParent.handle), lease = portalParent) null -> null } - // FileKit (0.16) accepts only an AWT parent on macOS: an NSWindow would make the picker throw. + // runModal is already app-modal on macOS; FileKit also rejects any non-AWT parent there. else -> null }