From 82a3b2d757f44dc4d51f0d434eff5143c00b4c45 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Thu, 30 Jul 2026 17:23:37 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=93=9D=20Document=20unified=20JVM=20d?= =?UTF-8?q?ialog=20parenting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0002-use-one-typed-jvm-dialog-parent.md | 19 + specs/jvm-dialog-parent/CONTEXT.md | 41 ++ specs/jvm-dialog-parent/PLAN.md | 486 ++++++++++++++++++ 3 files changed, 546 insertions(+) create mode 100644 specs/adr/0002-use-one-typed-jvm-dialog-parent.md create mode 100644 specs/jvm-dialog-parent/CONTEXT.md create mode 100644 specs/jvm-dialog-parent/PLAN.md diff --git a/specs/adr/0002-use-one-typed-jvm-dialog-parent.md b/specs/adr/0002-use-one-typed-jvm-dialog-parent.md new file mode 100644 index 00000000..c1c5e8b1 --- /dev/null +++ b/specs/adr/0002-use-one-typed-jvm-dialog-parent.md @@ -0,0 +1,19 @@ +--- +status: proposed +--- + +# Use One Typed JVM Dialog Parent + +FileKit will represent JVM dialog ownership with one canonical `FileKitDialogParent` value. Public factories will distinguish AWT windows, Windows HWNDs, X11 XIDs, and Wayland exported handles; FileKit will not accept an untyped `Long`, `Any`, framework window object, or Nucleus/Tao handle. `FileKitDialogSettings.parentWindow` is replaced by `parent`; AWT callers migrate explicitly through `FileKitDialogParent.awt(window)`. + +## Considered Options + +- Adding a nullable `Long` beside `parentWindow` was rejected because the same primitive cannot safely distinguish an HWND, XID, NSView, `wl_surface*`, Tao handle, or Wayland exported token, and two stored parent fields create precedence and equality problems. +- Accepting framework objects such as `NucleusWindow` was rejected because it would couple FileKit's public interface to individual window frameworks. +- Keeping public parent variants was rejected because callers only need validated construction; opaque variants keep platform conversion and failure rules local to FileKit. +- Silently discarding an incompatible parent was rejected because a dialog that unexpectedly loses modality or stacking is a correctness failure. +- Preserving the 0.14.2 JVM constructor and data-class descriptors with compatibility overloads was rejected after a breaking change was explicitly accepted; it would require a regular class, version overloads, and manual value semantics for no behavioral benefit. + +## Consequences + +`FileKitDialogSettings` remains a data class with one `parent: FileKitDialogParent?` property. The 0.14.2 JVM constructor, `parentWindow` getter, destructuring, and `copy` descriptors break in 0.15.0; migration is direct and documented. Windows/Tao can be parented immediately. Nucleus/Tao on Linux still needs a public X11 XID or `xdg_foreign` exported handle, and macOS sheet parenting remains a separate design. diff --git a/specs/jvm-dialog-parent/CONTEXT.md b/specs/jvm-dialog-parent/CONTEXT.md new file mode 100644 index 00000000..9d4ac8a8 --- /dev/null +++ b/specs/jvm-dialog-parent/CONTEXT.md @@ -0,0 +1,41 @@ +# JVM Dialog Parenting + +This context describes how FileKit identifies the application window that owns a JVM file dialog. It distinguishes portable window objects from platform identifiers whose meaning and lifetime belong to a specific desktop system. + +## Language + +**Dialog Parent**: +A borrowed identity for the application window that owns a dialog for modality, focus, and stacking. +_Avoid_: Parent window handle, native handle + +**AWT Parent**: +A dialog parent represented by a live `java.awt.Window`. +_Avoid_: Classic parent, Compose parent + +**Native Parent Identifier**: +A borrowed, platform-specific identity that the operating system or desktop portal accepts as a dialog parent. +_Avoid_: Generic handle, raw handle + +**Windows Owner Handle**: +A nonzero Win32 HWND identifying the window that owns a Windows dialog. +_Avoid_: Windows pointer, Tao handle + +**X11 Window ID**: +A nonzero XID identifying an X11 window. +_Avoid_: X11 handle, decimal window ID + +**Wayland Exported Handle**: +An opaque string issued by the `xdg_foreign` protocol for a live exported surface. +_Avoid_: Wayland pointer, `wl_surface` handle + +**Portal Parent String**: +The complete XDG portal value serialized by FileKit: `""` for no parent, `x11:`, or `wayland:`. +_Avoid_: Native handle, raw portal handle + +**Tao Handle**: +An opaque Nucleus/Tao event-loop identity that is not, by itself, a native parent identifier. +_Avoid_: Native window handle + +**Unparented Dialog**: +A dialog opened without an owner identity. +_Avoid_: Root dialog, default parent diff --git a/specs/jvm-dialog-parent/PLAN.md b/specs/jvm-dialog-parent/PLAN.md new file mode 100644 index 00000000..8d57eeca --- /dev/null +++ b/specs/jvm-dialog-parent/PLAN.md @@ -0,0 +1,486 @@ +# Unified JVM Dialog Parent + +## Status + +Validated after five review rounds by Claude Opus 5 and GPT-5.6-sol (high). + +Request: [Kotlin Slack Nucleus 2.0 thread](https://kotlinlang.slack.com/archives/C0BJ0GTE2/p1784024682833589?thread_ts=1783938008.832499&cid=C0BJ0GTE2) + +Prior prototype: [PR #624](https://github.com/vinceglb/FileKit/pull/624), closed without merge + +Baseline: `origin/main` at `1c0d166f786e9ddf17e8f6fd814cd46aa12f8761` + +Target release: `0.15.0` + +Compatibility decision: a breaking JVM API migration is explicitly accepted to keep the model small and direct. + +## Objective + +Allow JVM file dialogs to be owned by non-AWT windows without coupling FileKit to Nucleus or conflating platform identifiers. Keep the existing AWT capability through an explicit typed adapter, but replace the old `parentWindow` settings property rather than preserving its source or binary shape. + +The first practical integration is a Nucleus 2.1.10 Tao window on Windows, where Nucleus exposes a Win32 HWND. The public FileKit model must also correctly represent X11 and Wayland portal parents so the interface does not become Windows-specific. + +## Verified Problem and Constraints + +- `FileKitDialogSettings` currently stores only `parentWindow: java.awt.Window?`. +- Windows passes that AWT window through JNA to obtain an HWND for `IModalWindow.Show`. +- The Linux XDG portal requires: + - `x11:`, with the XID written in hexadecimal; or + - `wayland:`, where the handle is an opaque token obtained from `xdg_foreign`. +- FileKit currently emits `X11:`, which does not follow the documented XDG format. +- A raw Wayland `wl_surface*` is not an XDG portal parent. The exported handle's text has no documented grammar beyond being an opaque string, so FileKit must not reject it with pointer-shape heuristics. +- Linux falls back from the XDG portal to AWT/Swing. Those fallbacks can consume an AWT window but cannot consume a Tao, X11, or Wayland parent identifier. +- macOS/JVM currently calls `runModal()` and ignores the AWT parent. True window-modal presentation would require an NSWindow and the asynchronous sheet lifecycle. +- The resolved Nucleus 2.1.10 sources expose: + - `NucleusWindow.unsafe.awtWindow` for the AWT backend; + - `NucleusWindow.unsafe.taoWindow`; + - `TaoWindow.nativeHandle`, which is an HWND on Windows, an NSView pointer on macOS, and `0` on Linux; + - `NucleusWindow.unsafe.taoHandle`, which is an opaque Tao event-loop identity rather than an OS dialog-parent identifier. +- Nucleus 2.1.10 therefore enables a FileKit Tao parent directly on Windows only. Linux requires a future public Nucleus portal-parent contract, and macOS needs a separate FileKit sheet design. +- `FileKitDialogSettings` 0.14.2 is a JVM data class whose second property is `parentWindow`. Replacing it with `parent` changes the constructor, getter, destructuring, `copy`, and JVM descriptors. That break is intentional for 0.15.0. +- Existing AWT callers migrate from `parentWindow = window` to `parent = FileKitDialogParent.awt(window)`. +- The current documentation's `FileKitDialogSettings(this.window)` example does not match the 0.14.2 constructor order and must be corrected, not preserved. +- PR #624 proved the platform forwarding seams and CI viability, but its overload-based settings design left positional `null` ambiguous, gave AWT wrappers allocation identity instead of value equality, and contained a nullable-unsafe replacement expression. + +Primary references: + +- [XDG portal window identifiers](https://flatpak.github.io/xdg-desktop-portal/docs/window-identifiers.html) +- [XDG FileChooser portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html) +- [Windows `IModalWindow::Show`](https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-imodalwindow-show) +- [Nucleus 2.1.10 tag](https://github.com/NucleusFramework/Nucleus/tree/v2.1.10) + +## Autonomous Grilling Record + +The requested grilling session was resolved without user interruption. Facts were checked against the repository, the current resolved Nucleus 2.1.10 source JARs, and platform documentation. The initial compatibility-heavy design was discarded after the explicit decision to accept a breaking change. A temporary Kotlin 2.4.10 prototype compiled the final sealed/internal hierarchy without warnings. + +### 1. Is this a Nucleus-specific integration? + +**Recommendation and decision:** No. Treat Nucleus as the first consumer of a framework-neutral FileKit seam. + +FileKit should accept the exact identity required by its platform adapter. A dependency on `NucleusWindow`, Tao, Compose Desktop, LWJGL, or another framework would move third-party knowledge into FileKit's public interface and require a new adapter for every framework. + +### 2. Should FileKit add a second raw handle beside `parentWindow`? + +**Recommendation and decision:** No. Store one canonical `FileKitDialogParent?`. + +Separate `parentWindow` and `nativeParent` values require precedence rules, create invalid combinations, and make equality and copy semantics unstable. A generic `Long` also cannot represent Wayland's string token and cannot distinguish an HWND, XID, NSView, native pointer, or Tao handle. + +### 3. What should the public interface expose? + +**Recommendation and decision:** A public opaque `FileKitDialogParent` with validated companion factories: + +```kotlin +FileKitDialogParent.awt(window) +FileKitDialogParent.windows(hwnd) +FileKitDialogParent.x11(xid) +FileKitDialogParent.wayland(exportedHandle) +``` + +Concrete variants are nested `internal` implementations of the sealed class. Callers outside the module can only use the factories; platform adapters inside FileKit can resolve the internal variants without reflection or public downcasts. + +Do not add `native(Long)`, `handle(Any)`, public subclasses, or a caller-implemented resolver interface. + +### 4. Does FileKit own the parent or its lifetime? + +**Recommendation and decision:** No. Every parent is borrowed. + +The caller must keep the AWT window, HWND, X11 window, or Wayland export alive until the suspending picker call completes. FileKit never destroys, dereferences beyond the required platform conversion, or extends the lifetime of the owning window. In particular, the Wayland exporter object must remain alive because destroying it invalidates the exported token. + +### 5. How strict should factory validation be? + +**Recommendation and decision:** + +- AWT: accept a non-null `Window`. +- Windows: reject `0`; accept other `Long` bit patterns, including negative values that can represent a pointer's high bit. +- X11: accept `1..0xffffffff` and format it internally as bare lowercase hexadecimal, without `0x`, padding, or leading zeroes. +- Wayland: reject only an empty string and an embedded NUL. Treat every other character as opaque and add the `wayland:` portal prefix internally. + +Document that `wayland()` receives the unprefixed `xdg_foreign` handle. Do not trim it, parse it, or reject decimal-, hexadecimal-, whitespace-, or pointer-shaped text: the protocol defines an opaque string, not a FileKit-owned grammar. + +The validation asymmetry is intentional: the XDG contract defines an X11 XID as an unsigned 32-bit identifier, while an HWND is a pointer-width value carried in a Kotlin `Long`. FileKit range-checks the former but only rejects the null bit pattern for the latter. + +Invalid factory arguments fail immediately with `IllegalArgumentException`. `FileKitPickerException` is reserved for a valid parent that cannot be used by the selected picker or resolved at dialog-open time. + +### 6. What happens when a parent is incompatible with the active picker? + +**Recommendation and decision:** Fail before opening the dialog with a descriptive `FileKitPickerException`. + +- Windows accepts no parent, AWT, or Windows. +- The XDG portal accepts no parent, AWT/X11, X11, or Wayland. +- Linux's AWT `FileDialog` fallbacks accept no parent or an AWT `Frame`/`Dialog`; an AWT `Window` of another subtype fails rather than being silently cast to null. The Swing directory fallback accepts any AWT `Window`. +- macOS accepts no parent or AWT for compatibility; AWT remains application-modal because the current implementation uses `runModal()`. +- Every other pairing fails explicitly. + +Silently opening an unparented dialog would make modality and stacking nondeterministic and would hide integration bugs. + +On ownership-capable adapters, only an absent parent resolves to an unparented dialog. If JNA cannot obtain a usable HWND/XID from a supplied AWT window because it is unrealized, disposed, returns zero, or otherwise fails, throw a descriptive `FileKitPickerException`; retain the cause when conversion threw. Rejecting a zero identifier is a new deterministic check. + +There are two documented exceptions to parent preservation: + +- macOS accepts an AWT parent for migration compatibility but deliberately ignores it because `runModal()` is application-modal; +- Compose `WindowScope` overloads deliberately replace any caller-supplied parent with the scope's AWT window, preserving their existing contract. + +### 7. Should this change Linux fallback selection? + +**Recommendation and decision:** No. + +Keep portal-first selection and the existing AWT/Swing fallbacks. If the portal is unavailable and the caller supplied a native X11 or Wayland parent, throw rather than falling back unparented. This is new behavior enabled by the new parent kinds and must be documented. A separate future change may add another native Linux picker, but this feature must not broaden picker selection. + +### 8. Should macOS Tao parenting be included? + +**Recommendation and decision:** No. + +Nucleus exposes an NSView, while AppKit sheet presentation requires an owning NSWindow and a completion-handler lifecycle. FileKit's current synchronous `runModal()` implementation is application-modal and ignores `parentWindow`. Converting the NSView, bridging an AppKit block, suspending until completion, and handling cancellation/lifetime is a separate feature with different risks. + +This plan preserves current macOS behavior and documents that neither AWT nor Tao establishes window-modal sheet ownership yet. + +### 9. How should `FileKitDialogSettings` migrate? + +**Recommendation and decision:** Make the breaking change directly and keep the type a data class: + +```kotlin +public actual data class FileKitDialogSettings( + public val title: String? = null, + public val parent: FileKitDialogParent? = null, + public val macOS: FileKitMacOSSettings = FileKitMacOSSettings(), +) +``` + +Remove `parentWindow` completely. Do not add a deprecated adapter constructor, getter, version overload, or manually reproduced data-class members. AWT callers use `parent = FileKitDialogParent.awt(window)`. + +The generated data-class behavior is the new contract: `component2()` and `copy` use `FileKitDialogParent?`, and equality/hash code operate on that canonical value. + +### 10. What are the equality and diagnostic-string semantics? + +**Recommendation and decision:** Parent variants compare structurally, but their strings expose only the parent kind. + +- AWT parents wrapping the same `Window` compare equal. +- HWNDs, XIDs, and Wayland handles compare by value. +- `FileKitMacOSSettings` retains its existing reference equality. +- `FileKitDialogParent.toString()` returns a kind-only value such as `FileKitDialogParent.Windows`; it never prints an AWT object, HWND, XID, or Wayland token. +- Generated `FileKitDialogSettings.toString()` therefore remains useful without leaking borrowed identifiers. + +Internal parent variants may be data classes with an explicit redacted `toString()`. + +### 11. Which parent wins in Compose `WindowScope` extensions? + +**Recommendation and decision:** The scope's AWT window remains authoritative. + +The current JVM `WindowScope.remember*Launcher` overloads always replace the caller's parent with `this.window`. Preserve that behavior by copying the settings with `parent = FileKitDialogParent.awt(window)`. This also prevents a stale or platform-incompatible parent from escaping an AWT window scope. + +### 12. How should Nucleus demonstrate the feature? + +**Recommendation and decision:** Keep the FileKit library framework-neutral and wire only the existing `:sample:nucleusApp`. + +The sample may depend directly on `projects.filekitDialogs` and adapt its `NucleusWindow` locally: + +- AWT backend: `unsafe.awtWindow` can become `FileKitDialogParent.awt`. +- Tao/Windows: a nonzero `unsafe.taoWindow.nativeHandle` can become `FileKitDialogParent.windows`. +- Tao/Linux and Tao/macOS: return no parent and explain the current upstream/FileKit limitation. + +Gate the native handle by Nucleus's current platform before conversion because the same property is an NSView on macOS. Do not pass `unsafe.taoHandle`. + +Reuse the shared sample UI by threading a narrow dialog-settings transform from `nucleusApp`; do not duplicate a demonstration screen or add Nucleus to a published FileKit module. + +### 13. Does this need an ADR and public documentation? + +**Recommendation and decision:** Yes. + +The single canonical parent is a hard-to-reverse public seam with non-obvious rejected alternatives, so record it in `specs/adr/0002-use-one-typed-jvm-dialog-parent.md`. Update the JVM dialog-settings documentation because the public construction model and platform limitations change. Keep the domain glossary in this specification directory. + +## Public Interface Contract + +Add in `jvmMain`: + +```kotlin +public sealed class FileKitDialogParent { + internal data class Awt(internal val window: Window) : + FileKitDialogParent() + + internal data class Windows(internal val hwnd: Long) : + FileKitDialogParent() + + internal data class X11(internal val xid: Long) : + FileKitDialogParent() + + internal data class Wayland(internal val exportedHandle: String) : + FileKitDialogParent() + + public companion object { + public fun awt(window: Window): FileKitDialogParent + public fun windows(hwnd: Long): FileKitDialogParent + public fun x11(xid: Long): FileKitDialogParent + public fun wayland(exportedHandle: String): FileKitDialogParent + } +} +``` + +Replace JVM settings with: + +```kotlin +public actual data class FileKitDialogSettings( + public val title: String? = null, + public val parent: FileKitDialogParent? = null, + public val macOS: FileKitMacOSSettings = FileKitMacOSSettings(), +) +``` + +There is no compatibility constructor or duplicate AWT property. This is the complete settings surface. + +## Implementation Plan + +### 1. Introduce the opaque parent module + +Add: + +- `filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt` + +Implement: + +1. Nested internal structural variants for AWT, Windows, X11, and Wayland, each with a kind-only `toString()`. The sealed class uses its default protected constructor, so the shown hierarchy compiles while external callers remain factory-only. +2. The four public factories and validation rules from the grilling record. Invalid arguments throw `IllegalArgumentException`. +3. Internal resolvers for: + - AWT window projection; + - Windows HWND resolution, using `Native.getWindowPointer(window)` plus `Pointer.nativeValue(...)` in production; + - XDG portal identifier resolution, with `Native.getWindowID(window)` as the production AWT-to-XID converter and an injected converter for tests; + - AWT-only fallback validation; + - macOS compatibility validation. +4. Descriptive `FileKitPickerException` messages that identify the supplied parent kind and supported kinds without leaking raw pointer/token values. +5. AWT conversion failures are reported as `FileKitPickerException`. Preserve the original cause for a thrown JNA conversion; a zero result has no cause. An unrealized, disposed, zero-ID, or otherwise unusable AWT parent is not silently converted to an unparented dialog. +6. Put the common zero/exception mapping behind a window-free internal helper that accepts a `() -> Long`. Production passes the JNA calls above; unit tests inject success, zero, and throwing lambdas without constructing an AWT window. + +The resolvers are the module's test surface. Platform pickers must not inspect parent variants directly. + +### 2. Replace the JVM settings parent + +Update: + +- `filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.jvm.kt` + +Changes: + +1. Keep the class as a data class. +2. Replace `parentWindow: Window?` in the second position with `parent: FileKitDialogParent?`. +3. Keep `title`, `macOS`, and `createDefault()` behavior unchanged. +4. Rely on generated `componentN`, `copy`, equality, hash code, and settings `toString`. +5. Update all repository callers in the same change. + +Do not add a legacy constructor/getter, version overload, window-first constructor, public platform variants, or generic native handle. + +### 3. Route each JVM picker through the canonical parent + +Update: + +- `filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsFilePicker.kt` +- `filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt` +- `filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/linux/LinuxFilePicker.kt` +- `filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt` +- `filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt` +- `filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt` +- `filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt` + +Changes: + +1. Windows resolves the parent to `Long?`, converts it to `WinDef.HWND(Pointer(handle))`, and passes it to every Open, Multi, Directory, and Save `Show` call. +2. Preserve the dedicated `FileKit-Windows-Dialog` STA executor and `FOS_FORCEFILESYSTEM`. +3. Extract an internal Windows show boundary that accepts the already-resolved `HWND?` and an injected `Show` call. Production delegates to `FileDialog.Show`; JVM tests use a recording lambda and never initialize COM. +4. XDG resolves once to: + - `""` for no parent; + - `x11:` for AWT/X11, with no `0x`, padding, or leading zeroes; + - `wayland:` for Wayland. +5. Put D-Bus `OpenFile`/`SaveFile` calls behind a small internal XDG transport. Production uses the current D-Bus objects; tests record the complete parent identifier without connecting to a session bus. +6. Use the same resolver for both `OpenFile` and `SaveFile`. +7. Both `AwtFilePicker` and `AwtFileSaver` use one shared internal `resolveAwtFileDialogOwner` helper. It projects only `Frame`/`Dialog`; unsupported AWT `Window` subtypes fail. Extract the class predicate from instantiation so its accept/reject matrix is testable without constructing a window in a headless JVM. Swing projects any AWT `Window`. All non-AWT parents fail on either fallback. +8. Type the XDG, AWT, and Swing Linux delegates as `PlatformFilePicker`, allowing recording fakes without a mocking dependency. Production construction still supplies the existing concrete pickers. +9. Inject portal availability separately as `{ xdgFilePickerPortal.isAvailable() }`, and cache its result with the existing lazy behavior. +10. Linux selects the same portal/fallback implementation as before; native parents fail if only an AWT/Swing fallback is available. +11. macOS validates that no non-AWT native parent was supplied, then retains `runModal()`. + +Keep cancellation, result extraction, COM balancing, D-Bus response handling, native picker threading, and file-system option behavior unchanged. + +### 4. Preserve Compose AWT injection + +Update: + +- `filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt` + +Change the existing private `injectDialogSettings` function to `internal` and make it accept an already-created `FileKitDialogParent` rather than a `Window`. Each `WindowScope` overload passes `FileKitDialogParent.awt(this.window)`; the helper copies or creates settings with that value. Add `filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeJvmTest.kt` to call the helper with a native parent and prove both null settings and an existing parent are replaced without constructing an AWT window. The convention plugin already wires `jvmTest` to `commonTest`, which provides `kotlin.test`; no new test dependency is needed. + +### 5. Wire the existing Nucleus sample + +Update: + +- `sample/nucleusApp/build.gradle.kts` +- `sample/nucleusApp/src/main/kotlin/io/github/vinceglb/filekit/sample/nucleus/Main.kt` +- the narrow shared-sample files needed to pass a dialog-settings transform + +Changes: + +1. Add a direct `projects.filekitDialogs` dependency. +2. Read the current `nucleusWindow` inside `DecoratedWindow`. +3. Convert AWT and Tao/Windows parents locally; never pass `taoHandle`. +4. Pass the result through a settings transform to the existing shared picker UI and use the plain, non-`WindowScope` launcher path so Compose does not replace the Tao HWND. +5. Leave Tao/Linux and Tao/macOS unparented with a source comment explaining why. + +Do not add a Nucleus adapter to a published FileKit module or duplicate the sample UI. + +### 6. Update public documentation + +Update: + +- `docs/dialogs/dialog-settings.mdx` +- public KDoc on `FileKitDialogParent` and `FileKitDialogSettings` + +Document: + +1. Canonical AWT, Windows, X11, and Wayland construction examples. +2. The platform compatibility matrix, including Linux fallback failure. +3. Borrowed lifetime requirements. +4. The difference between an unprefixed Wayland exported handle, raw `wl_surface*`, and Tao handle; FileKit adds the `wayland:` prefix and does not normalize caller input. +5. The breaking AWT migration from `parentWindow = window` to `parent = FileKitDialogParent.awt(window)`. +6. A Nucleus/Tao Windows example gated by platform. +7. Current Nucleus Linux and macOS limitations. +8. The existing macOS `runModal()` behavior. +9. A corrected Compose example using `parent = FileKitDialogParent.awt(this.window)`. +10. Factory validation (`IllegalArgumentException`), picker-resolution failures (`FileKitPickerException`), and the new Linux failure when a native parent cannot use the portal fallback. + +Do not claim cross-platform Tao parenting until Nucleus exposes a compatible Linux identifier and FileKit supports macOS sheets. + +## Test Plan + +### Parent model tests + +Add: + +- `filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt` + +Cover: + +1. AWT structural equality for the same `Window` when a graphics environment is available. +2. Windows zero rejection as `IllegalArgumentException` and preservation of positive/negative nonzero bit patterns. +3. X11 zero/negative/out-of-range rejection as `IllegalArgumentException`. +4. Wayland empty/NUL rejection as `IllegalArgumentException`. +5. Wayland opacity using the XDG documentation example plus whitespace-, decimal-, hexadecimal-shaped, and already-`wayland:`-prefixed tokens. +6. Correct no-parent result. +7. Explicit platform mismatch failures. +8. AWT-only fallback and macOS compatibility rules. +9. Kind-only `toString()` for every parent variant, with no raw identifier or AWT object text. + +### Settings model tests + +Add: + +- `filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettingsTest.kt` + +Runtime/value cases: + +1. Defaults produce no parent. +2. AWT and native parents participate in data-class equality and hash code; equality fixtures reuse the same `FileKitMacOSSettings` instance because that class intentionally has reference equality. +3. Copying only title/macOS preserves the parent. +4. `copy(parent = null)` clears any parent. +5. `component2()` returns the canonical parent. +6. Settings `toString()` contains the parent kind but not its raw value. + +### Platform forwarding tests + +Add or extend: + +- `filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogParentTest.kt` +- `filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgDialogParentTest.kt` +- `filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogOwnerTest.kt` +- `filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/linux/LinuxFilePickerTest.kt` +- `filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeJvmTest.kt` + +Cover: + +1. Exact raw HWND forwarding to `Show`, including negative bit patterns; no parent passes a true null owner. +2. Window-free `() -> Long` conversion tests prove AWT-to-HWND success, thrown conversion, and zero-result mapping; failures become `FileKitPickerException`, not an unparented dialog. +3. XDG Open and Save receive the same correct parent string. +4. Exact X11 examples, including `1 -> "x11:1"`, `0x2a -> "x11:2a"`, and `0xffffffff -> "x11:ffffffff"`. +5. The same window-free seam proves AWT-to-XID success, thrown conversion, and zero-result mapping as `FileKitPickerException`. +6. Wayland handles receive exactly one FileKit-added prefix and are otherwise unchanged; an input of `"wayland:token"` intentionally resolves to `"wayland:wayland:token"`. +7. Injected portal availability deterministically covers Open, Multi, Directory, and Save routing in both available and unavailable states. +8. All four fallback routes reject X11/Wayland parents rather than discarding ownership. +9. Recording `PlatformFilePicker` fakes prove all eight Linux routing cases without opening AWT/Swing UI or D-Bus. +10. A host-independent class predicate accepts `Frame`/`Dialog` and rejects another `Window` class for AWT `FileDialog` ownership; the rejection path throws `FileKitPickerException`. +11. Picker and saver tests each supply an incompatible native parent and prove the shared AWT owner resolver fails before any UI opens. +12. A recording Windows show boundary proves exact `HWND?` forwarding without COM. +13. A recording XDG transport proves exact Open/Save parent strings without D-Bus. +14. Existing Windows STA and required option tests remain unchanged and green. + +Any residual test that constructs a real `Window` uses a `GraphicsEnvironment.isHeadless()` assumption and records whether it ran or skipped. The existing three-OS `jvmTest` matrix must execute those tests on at least one non-headless host; if every current runner skips them, add an Xvfb-backed Linux job. The Windows Nucleus smoke test remains the required end-to-end proof of actual AWT/Tao owner behavior. + +### Integration/manual verification + +1. On Windows, run `:sample:nucleusApp`, open every picker/saver mode, and verify: + - the dialog stays above the Tao window; + - the Tao window cannot be interacted with while the modal dialog is open; + - focus returns to the Tao window after accept and cancel; + - the Tao event loop continues pumping while FileKit's dedicated STA thread owns the dialog, with no deadlock on either accept or cancel. +2. On Linux/X11 with a test provider capable of supplying an XID, verify portal stacking and modality. +3. On Linux/Wayland with a live `xdg_foreign` export, verify portal stacking and keep the exporter alive until completion. +4. Verify the documented failure when a native Linux parent is used with no available portal. +5. On macOS, confirm the existing application-modal picker still works and no sheet-parenting claim is made. +6. Compile and package the Nucleus sample for the current host; Windows manual behavior remains the required proof for the requested Tao integration. + +## Acceptance Criteria + +- FileKit exposes one canonical JVM dialog parent and no generic/native catch-all handle. +- AWT callers can express the same ownership through `FileKitDialogParent.awt`. +- Windows/Tao passes the exact nonzero HWND to every native dialog mode. +- XDG emits `x11:` as documented and FileKit canonicalizes the XID to bare lowercase hexadecimal; Wayland emits `wayland:`. +- Wayland values remain opaque; raw pointers are documented as invalid but are not guessed from string shape. +- Ownership-capable platform adapters never silently discard a supplied parent because it is incompatible or because AWT-to-native conversion fails. +- The documented exceptions are macOS `runModal()` ignoring an AWT parent and `WindowScope` deliberately replacing the caller's parent. +- Parent identities are borrowed and their lifecycle contract is documented. +- `FileKitDialogSettings` remains a data class with `parent` as its sole parent property. +- The source and binary break from 0.14.2 is documented with an AWT migration example. +- `WindowScope` continues to inject its own AWT window. +- FileKit's published modules do not depend on Nucleus. +- The Nucleus sample uses `nativeHandle` only on Windows and never passes `taoHandle`, a macOS NSView, Linux zero, or a raw Wayland surface as an HWND. +- macOS sheet parenting and Nucleus Linux parent extraction remain explicitly out of scope. +- Focused tests, JVM/module checks, repository checks, assembly, formatting, and CI pass. + +## Verification + +Run in this order: + +```bash +./gradlew :filekit-dialogs:jvmTest \ + --tests 'io.github.vinceglb.filekit.dialogs.FileKitDialogParentTest' \ + --tests 'io.github.vinceglb.filekit.dialogs.FileKitDialogSettingsTest' \ + --tests 'io.github.vinceglb.filekit.dialogs.platform.windows.WindowsDialogParentTest' \ + --tests 'io.github.vinceglb.filekit.dialogs.platform.xdg.XdgDialogParentTest' \ + --tests 'io.github.vinceglb.filekit.dialogs.platform.awt.AwtDialogOwnerTest' \ + --tests 'io.github.vinceglb.filekit.dialogs.platform.linux.LinuxFilePickerTest' \ + --no-daemon + +./gradlew :filekit-dialogs-compose:jvmTest \ + --tests 'io.github.vinceglb.filekit.dialogs.compose.FileKitComposeJvmTest' \ + --no-daemon + +./gradlew :filekit-dialogs:check :filekit-dialogs-compose:check --no-daemon +./gradlew :sample:nucleusApp:compileKotlin --no-daemon +./gradlew check --no-daemon +./gradlew assemble --no-daemon +./gradlew :sample:nucleusApp:packageDistributionForCurrentOS --no-daemon + +ktlint '**/*.kt' '**/*.kts' '!**/build/**' -R ktlint-compose-0.4.28-all.jar +git diff --check +``` + +On Windows, record the JDK, Nucleus version, backend, and results of the modal/focus smoke test. + +## Out of Scope + +- Production implementation during the planning/review phase. +- A generic `Long`, `Any`, Nucleus object, Tao object, or caller-defined adapter in the FileKit public interface. +- A published FileKit-Nucleus integration module. +- Extracting Nucleus internal Linux handles. +- Treating a raw `wl_surface*` as a Wayland portal parent. +- Exporting a Wayland surface on the caller's behalf. +- macOS NSView-to-NSWindow conversion or sheet presentation. +- Changing Linux portal-first picker selection. +- Changing Windows COM threading, options, cancellation, or file-result behavior. +- Changing native Windows/Kotlin, macOS/Kotlin Native, mobile, or web dialog settings. +- Version bumping, publishing, or release work. From db707ea6fbe6406382a6b33818e4802ffd76e478 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Thu, 30 Jul 2026 17:42:31 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20Add=20typed=20JVM=20dialog=20pa?= =?UTF-8?q?rents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dialogs/compose/FileKitCompose.jvm.kt | 18 +- .../dialogs/compose/FileKitComposeJvmTest.kt | 40 ++++ .../dialogs/FileKitDialogParent.jvm.kt | 195 ++++++++++++++++++ .../dialogs/FileKitDialogSettings.jvm.kt | 7 +- .../dialogs/platform/PlatformFilePicker.kt | 20 +- .../dialogs/platform/awt/AwtDialogParent.kt | 23 +++ .../dialogs/platform/awt/AwtFilePicker.kt | 4 +- .../dialogs/platform/awt/AwtFileSaver.kt | 8 +- .../dialogs/platform/linux/LinuxFilePicker.kt | 9 +- .../dialogs/platform/mac/MacOSFilePicker.kt | 53 +++-- .../dialogs/platform/swing/SwingFilePicker.kt | 4 +- .../platform/windows/WindowsFilePicker.kt | 35 ++-- .../platform/xdg/XdgFilePickerPortal.kt | 194 ++++++++++------- .../dialogs/FileKitDialogParentTest.kt | 124 +++++++++++ .../dialogs/FileKitDialogSettingsTest.kt | 45 ++++ .../platform/awt/AwtDialogOwnerTest.kt | 52 +++++ .../platform/linux/LinuxFilePickerTest.kt | 128 ++++++++++++ .../windows/WindowsDialogParentTest.kt | 35 ++++ .../platform/xdg/XdgDialogParentTest.kt | 80 +++++++ 19 files changed, 936 insertions(+), 138 deletions(-) create mode 100644 filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeJvmTest.kt create mode 100644 filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt create mode 100644 filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogParent.kt create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettingsTest.kt create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogOwnerTest.kt create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/linux/LinuxFilePickerTest.kt create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogParentTest.kt create mode 100644 filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgDialogParentTest.kt diff --git a/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt b/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt index 8123857d..a83af69c 100644 --- a/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt +++ b/filekit-dialogs-compose/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitCompose.jvm.kt @@ -5,10 +5,10 @@ package io.github.vinceglb.filekit.dialogs.compose import androidx.compose.runtime.Composable import androidx.compose.ui.window.WindowScope import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMode import io.github.vinceglb.filekit.dialogs.FileKitType -import java.awt.Window @Composable public fun WindowScope.rememberFilePickerLauncher( @@ -21,7 +21,7 @@ public fun WindowScope.rememberFilePickerLauncher type = type, mode = mode, directory = directory, - dialogSettings = injectDialogSettings(dialogSettings, this.window), + dialogSettings = injectDialogSettings(dialogSettings, FileKitDialogParent.awt(this.window)), onResult = onResult, ) @@ -34,7 +34,7 @@ public fun WindowScope.rememberFilePickerLauncher( ): PickerResultLauncher = io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher( type = type, directory = directory, - dialogSettings = injectDialogSettings(dialogSettings, this.window), + dialogSettings = injectDialogSettings(dialogSettings, FileKitDialogParent.awt(this.window)), onResult = onResult, ) @@ -45,7 +45,7 @@ public fun WindowScope.rememberDirectoryPickerLauncher( onResult: (PlatformFile?) -> Unit, ): PickerResultLauncher = io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher( directory = directory, - dialogSettings = injectDialogSettings(dialogSettings, this.window), + dialogSettings = injectDialogSettings(dialogSettings, FileKitDialogParent.awt(this.window)), onResult = onResult, ) @@ -54,13 +54,13 @@ public fun WindowScope.rememberFileSaverLauncher( dialogSettings: FileKitDialogSettings? = null, onResult: (PlatformFile?) -> Unit, ): SaverResultLauncher = io.github.vinceglb.filekit.dialogs.compose.rememberFileSaverLauncher( - dialogSettings = injectDialogSettings(dialogSettings, this.window), + dialogSettings = injectDialogSettings(dialogSettings, FileKitDialogParent.awt(this.window)), onResult = onResult, ) -private fun injectDialogSettings( +internal fun injectDialogSettings( dialogSettings: FileKitDialogSettings?, - window: Window, + parent: FileKitDialogParent, ): FileKitDialogSettings = dialogSettings - ?.copy(parentWindow = window) - ?: FileKitDialogSettings(parentWindow = window) + ?.copy(parent = parent) + ?: FileKitDialogSettings(parent = parent) diff --git a/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeJvmTest.kt b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeJvmTest.kt new file mode 100644 index 00000000..d0a4426c --- /dev/null +++ b/filekit-dialogs-compose/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/compose/FileKitComposeJvmTest.kt @@ -0,0 +1,40 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.compose + +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class FileKitComposeJvmTest { + @Test + fun injectDialogSettings_withoutExistingSettings_usesScopeParent() { + val scopeParent = FileKitDialogParent.windows(42) + + val result = injectDialogSettings( + dialogSettings = null, + parent = scopeParent, + ) + + assertSame(scopeParent, result.parent) + } + + @Test + fun injectDialogSettings_withExistingNativeParent_replacesItAndPreservesSettings() { + val scopeParent = FileKitDialogParent.windows(42) + val settings = FileKitDialogSettings( + title = "Choose", + parent = FileKitDialogParent.x11(7), + ) + + val result = injectDialogSettings( + dialogSettings = settings, + parent = scopeParent, + ) + + assertEquals("Choose", result.title) + assertSame(scopeParent, result.parent) + } +} diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt new file mode 100644 index 00000000..19c2f8e2 --- /dev/null +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt @@ -0,0 +1,195 @@ +package io.github.vinceglb.filekit.dialogs + +import java.awt.Window + +/** + * A borrowed JVM window identity that owns a FileKit dialog. + * + * Keep the represented window, native window, or Wayland export alive until the + * suspending picker call completes. FileKit never owns or destroys the parent. + * + * Create instances with [awt], [windows], [x11], or [wayland]. The concrete + * variants are intentionally hidden so a native identifier cannot be confused + * with an identifier from another window system. + */ +public sealed class FileKitDialogParent { + internal data class Awt( + internal val window: Window, + ) : FileKitDialogParent() { + override fun toString(): String = "FileKitDialogParent.Awt" + } + + internal data class Windows( + internal val hwnd: Long, + ) : FileKitDialogParent() { + override fun toString(): String = "FileKitDialogParent.Windows" + } + + internal data class X11( + internal val xid: Long, + ) : FileKitDialogParent() { + override fun toString(): String = "FileKitDialogParent.X11" + } + + internal data class Wayland( + internal val exportedHandle: String, + ) : FileKitDialogParent() { + override fun toString(): String = "FileKitDialogParent.Wayland" + } + + public companion object { + /** + * Uses a live AWT [Window] as the dialog parent. + */ + public fun awt(window: Window): FileKitDialogParent = Awt(window) + + /** + * Uses a non-zero Windows HWND as the dialog parent. + * + * Negative values are accepted because a signed [Long] can carry a pointer + * bit pattern whose high bit is set. + */ + public fun windows(hwnd: Long): FileKitDialogParent { + require(hwnd != 0L) { "A Windows HWND must not be zero." } + return Windows(hwnd) + } + + /** + * Uses an unsigned 32-bit X11 XID as the dialog parent. + */ + public fun x11(xid: Long): FileKitDialogParent { + require(xid in 1L..X11_XID_MAX) { + "An X11 XID must be between 1 and 0xffffffff." + } + return X11(xid) + } + + /** + * Uses an unprefixed `xdg_foreign` Wayland exported handle. + * + * The handle is opaque. FileKit only rejects an empty value or an embedded + * NUL, then adds the `wayland:` portal prefix without normalizing the text. + * A raw `wl_surface*` pointer is not a valid exported handle. + */ + public fun wayland(exportedHandle: String): FileKitDialogParent { + require(exportedHandle.isNotEmpty()) { + "A Wayland exported handle must not be empty." + } + require('\u0000' !in exportedHandle) { + "A Wayland exported handle must not contain NUL." + } + return Wayland(exportedHandle) + } + } +} + +internal fun FileKitDialogParent?.resolveWindowsHandle( + awtWindowHandle: (Window) -> Long, +): Long? = when (this) { + null -> null + + is FileKitDialogParent.Awt -> resolveAwtNativeIdentifier("Windows HWND") { + awtWindowHandle(window) + } + + is FileKitDialogParent.Windows -> hwnd + + else -> unsupportedParent( + adapter = "Windows dialogs", + supported = "AWT or Windows", + ) +} + +internal fun FileKitDialogParent?.resolveXdgPortalParent( + awtWindowXid: (Window) -> Long, +): String = when (this) { + null -> { + "" + } + + is FileKitDialogParent.Awt -> { + val xid = resolveAwtNativeIdentifier("X11 XID") { + awtWindowXid(window) + } + x11PortalParent(xid) + } + + is FileKitDialogParent.X11 -> { + x11PortalParent(xid) + } + + is FileKitDialogParent.Wayland -> { + "wayland:$exportedHandle" + } + + else -> { + unsupportedParent( + adapter = "XDG portal dialogs", + supported = "AWT, X11, or Wayland", + ) + } +} + +internal fun FileKitDialogParent?.requireAwtWindowOrNull( + adapter: String, +): Window? = when (this) { + null -> null + + is FileKitDialogParent.Awt -> window + + else -> unsupportedParent( + adapter = adapter, + supported = "AWT", + ) +} + +internal fun FileKitDialogParent?.requireMacOSCompatible() { + when (this) { + null, is FileKitDialogParent.Awt -> Unit + + else -> unsupportedParent( + adapter = "macOS dialogs", + supported = "AWT", + ) + } +} + +internal fun resolveAwtNativeIdentifier( + identifierName: String, + conversion: () -> Long, +): Long { + val identifier = try { + conversion() + } catch (cause: Exception) { + throw FileKitPickerException( + message = "The AWT dialog parent could not resolve to a usable $identifierName.", + cause = cause, + ) + } + + if (identifier == 0L) { + throw FileKitPickerException( + "The AWT dialog parent resolved to an invalid zero $identifierName.", + ) + } + + return identifier +} + +private fun x11PortalParent(xid: Long): String = "x11:${xid.toString(16)}" + +private fun FileKitDialogParent.unsupportedParent( + adapter: String, + supported: String, +): Nothing = throw FileKitPickerException( + "$adapter does not support ${kindName()} dialog parents. Supported parents: $supported.", +) + +private fun FileKitDialogParent.kindName(): String = when (this) { + is FileKitDialogParent.Awt -> "AWT" + is FileKitDialogParent.Windows -> "Windows" + is FileKitDialogParent.X11 -> "X11" + is FileKitDialogParent.Wayland -> "Wayland" +} + +private const val X11_XID_MAX: Long = 0xffff_ffffL diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.jvm.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.jvm.kt index 11cf7ff6..be17947a 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.jvm.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettings.jvm.kt @@ -1,17 +1,16 @@ package io.github.vinceglb.filekit.dialogs -import java.awt.Window - /** * JVM implementation of [FileKitDialogSettings]. * * @property title The title of the dialog. - * @property parentWindow The parent window for the dialog. + * @property parent The borrowed window identity that owns the dialog. Keep it + * alive until the suspending picker call completes. * @property macOS Specific settings for macOS when running on JVM. */ public actual data class FileKitDialogSettings( public val title: String? = null, - public val parentWindow: Window? = null, + public val parent: FileKitDialogParent? = null, public val macOS: FileKitMacOSSettings = FileKitMacOSSettings(), ) { public actual companion object { diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/PlatformFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/PlatformFilePicker.kt index d57b7b0e..7d886987 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/PlatformFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/PlatformFilePicker.kt @@ -49,9 +49,23 @@ internal interface PlatformFilePicker { val current: PlatformFilePicker by lazy { createPlatformFilePicker() } private fun createPlatformFilePicker(): PlatformFilePicker = when (PlatformUtil.current) { - Platform.MacOS -> MacOSFilePicker() - Platform.Windows -> WindowsFilePicker() - Platform.Linux -> LinuxFilePicker(XdgFilePickerPortal(), AwtFilePicker(), SwingFilePicker()) + Platform.MacOS -> { + MacOSFilePicker() + } + + Platform.Windows -> { + WindowsFilePicker() + } + + Platform.Linux -> { + val xdgFilePickerPortal = XdgFilePickerPortal() + LinuxFilePicker( + xdgFilePickerPortal = xdgFilePickerPortal, + awtFilePicker = AwtFilePicker(), + swingFilePicker = SwingFilePicker(), + isXdgFilePickerPortalAvailable = xdgFilePickerPortal::isAvailable, + ) + } } } } diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogParent.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogParent.kt new file mode 100644 index 00000000..4ab3d743 --- /dev/null +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogParent.kt @@ -0,0 +1,23 @@ +package io.github.vinceglb.filekit.dialogs.platform.awt + +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent +import io.github.vinceglb.filekit.dialogs.FileKitPickerException +import io.github.vinceglb.filekit.dialogs.requireAwtWindowOrNull +import java.awt.Dialog +import java.awt.Frame +import java.awt.Window + +internal fun FileKitDialogParent?.resolveAwtFileDialogOwner(): Window? { + val window = requireAwtWindowOrNull("AWT file dialogs") ?: return null + if (!isSupportedAwtFileDialogOwner(window.javaClass)) { + throw FileKitPickerException( + "AWT file dialogs require an AWT Frame or Dialog parent.", + ) + } + return window +} + +internal fun isSupportedAwtFileDialogOwner( + windowClass: Class, +): Boolean = Frame::class.java.isAssignableFrom(windowClass) || + Dialog::class.java.isAssignableFrom(windowClass) diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt index e196257d..9fff706c 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFilePicker.kt @@ -25,7 +25,7 @@ internal class AwtFilePicker : PlatformFilePicker { isMultipleMode = false, fileExtensions = fileExtensions, directory = directory, - parentWindow = dialogSettings.parentWindow, + parentWindow = dialogSettings.parent.resolveAwtFileDialogOwner(), )?.firstOrNull() override suspend fun openFilesPicker( @@ -37,7 +37,7 @@ internal class AwtFilePicker : PlatformFilePicker { isMultipleMode = true, fileExtensions = fileExtensions, directory = directory, - parentWindow = dialogSettings.parentWindow, + parentWindow = dialogSettings.parent.resolveAwtFileDialogOwner(), ) override suspend fun openDirectoryPicker( diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt index c3e7b6a4..2ff102c6 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtFileSaver.kt @@ -25,16 +25,18 @@ internal object AwtFileSaver { } } + val parentWindow = dialogSettings?.parent.resolveAwtFileDialogOwner() + // Handle parentWindow: Dialog, Frame, or null - val dialog = when (dialogSettings?.parentWindow) { - is Dialog -> object : FileDialog(dialogSettings.parentWindow, "Save dialog", SAVE) { + val dialog = when (parentWindow) { + is Dialog -> object : FileDialog(parentWindow, "Save dialog", SAVE) { override fun setVisible(value: Boolean) { super.setVisible(value) handleResult(value, files) } } - else -> object : FileDialog(dialogSettings?.parentWindow as? Frame, "Save dialog", SAVE) { + else -> object : FileDialog(parentWindow as? Frame, "Save dialog", SAVE) { override fun setVisible(value: Boolean) { super.setVisible(value) handleResult(value, files) diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/linux/LinuxFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/linux/LinuxFilePicker.kt index da9293df..b4d1ace0 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/linux/LinuxFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/linux/LinuxFilePicker.kt @@ -15,11 +15,12 @@ import java.io.File * [SwingFilePicker] is used for [openDirectoryPicker] because [AwtFilePicker] doesn't support picking directories */ internal class LinuxFilePicker( - private val xdgFilePickerPortal: XdgFilePickerPortal, - private val awtFilePicker: AwtFilePicker, - private val swingFilePicker: SwingFilePicker, + private val xdgFilePickerPortal: PlatformFilePicker, + private val awtFilePicker: PlatformFilePicker, + private val swingFilePicker: PlatformFilePicker, + isXdgFilePickerPortalAvailable: () -> Boolean, ) : PlatformFilePicker { - private val xdgFilePickerPortalAvailable by lazy { xdgFilePickerPortal.isAvailable() } + private val xdgFilePickerPortalAvailable by lazy(isXdgFilePickerPortalAvailable) override suspend fun openFilePicker( fileExtensions: Set?, diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt index 2fd58cf0..f591c5f7 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/mac/MacOSFilePicker.kt @@ -7,6 +7,7 @@ import io.github.vinceglb.filekit.dialogs.buildFileSaverAllowedFileTypes import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker import io.github.vinceglb.filekit.dialogs.platform.mac.foundation.Foundation import io.github.vinceglb.filekit.dialogs.platform.mac.foundation.ID +import io.github.vinceglb.filekit.dialogs.requireMacOSCompatible import io.github.vinceglb.filekit.path import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -17,36 +18,45 @@ internal class MacOSFilePicker : PlatformFilePicker { fileExtensions: Set?, directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): File? = callNativeMacOSPicker( - mode = MacOSFilePickerMode.SingleFile, - directory = directory, - fileExtensions = fileExtensions, - title = dialogSettings.title, - macOSSettings = dialogSettings.macOS, - ) + ): File? { + dialogSettings.parent.requireMacOSCompatible() + return callNativeMacOSPicker( + mode = MacOSFilePickerMode.SingleFile, + directory = directory, + fileExtensions = fileExtensions, + title = dialogSettings.title, + macOSSettings = dialogSettings.macOS, + ) + } override suspend fun openFilesPicker( fileExtensions: Set?, directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): List? = callNativeMacOSPicker( - mode = MacOSFilePickerMode.MultipleFiles, - directory = directory, - fileExtensions = fileExtensions, - title = dialogSettings.title, - macOSSettings = dialogSettings.macOS, - ) + ): List? { + dialogSettings.parent.requireMacOSCompatible() + return callNativeMacOSPicker( + mode = MacOSFilePickerMode.MultipleFiles, + directory = directory, + fileExtensions = fileExtensions, + title = dialogSettings.title, + macOSSettings = dialogSettings.macOS, + ) + } override suspend fun openDirectoryPicker( directory: PlatformFile?, dialogSettings: FileKitDialogSettings, - ): File? = callNativeMacOSPicker( - mode = MacOSFilePickerMode.Directories, - directory = directory, - fileExtensions = null, - title = dialogSettings.title, - macOSSettings = dialogSettings.macOS, - ) + ): File? { + dialogSettings.parent.requireMacOSCompatible() + return callNativeMacOSPicker( + mode = MacOSFilePickerMode.Directories, + directory = directory, + fileExtensions = null, + title = dialogSettings.title, + macOSSettings = dialogSettings.macOS, + ) + } override suspend fun openFileSaver( suggestedName: String, @@ -55,6 +65,7 @@ internal class MacOSFilePicker : PlatformFilePicker { directory: PlatformFile?, dialogSettings: FileKitDialogSettings, ): File? = withContext(Dispatchers.IO) { + dialogSettings.parent.requireMacOSCompatible() val pool = Foundation.NSAutoreleasePool() try { var response: File? = null diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt index 59fbf059..b4c2c214 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/swing/SwingFilePicker.kt @@ -3,6 +3,7 @@ package io.github.vinceglb.filekit.dialogs.platform.swing import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker +import io.github.vinceglb.filekit.dialogs.requireAwtWindowOrNull import io.github.vinceglb.filekit.path import kotlinx.coroutines.suspendCancellableCoroutine import java.io.File @@ -76,7 +77,8 @@ internal class SwingFilePicker : PlatformFilePicker { jFileChooser.dialogTitle = dialogSettings.title } - val returnValue = jFileChooser.showOpenDialog(dialogSettings.parentWindow) + val parentWindow = dialogSettings.parent.requireAwtWindowOrNull("Swing dialogs") + val returnValue = jFileChooser.showOpenDialog(parentWindow) if (returnValue == JFileChooser.APPROVE_OPTION) { continuation.resume( jFileChooser.selectedFiles.toList().takeIf { it.isNotEmpty() } ?: jFileChooser.selectedFile?.let { listOf(it) }, 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 88ae3202..570c2e41 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 @@ -1,6 +1,7 @@ package io.github.vinceglb.filekit.dialogs.platform.windows import com.sun.jna.Native +import com.sun.jna.Pointer import com.sun.jna.WString import com.sun.jna.platform.win32.COM.COMUtils.FAILED import com.sun.jna.platform.win32.Guid @@ -33,8 +34,8 @@ import io.github.vinceglb.filekit.dialogs.platform.windows.jna.Shell32 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.dialogs.resolveWindowsHandle import io.github.vinceglb.filekit.path -import java.awt.Window import java.io.File internal class WindowsFilePicker( @@ -60,7 +61,7 @@ internal class WindowsFilePicker( ?.takeIf { it.isNotEmpty() } ?.let { fileOpenDialog.addFiltersToDialog(it) } - fileOpenDialog.show(dialogSettings.parentWindow) { + fileOpenDialog.show(dialogSettings.resolveWindowsDialogHandle()) { it.getResult(SIGDN_FILESYSPATH) } } @@ -88,7 +89,7 @@ internal class WindowsFilePicker( // Set a flag for multiple options fileOpenDialog.setFlag(FOS_ALLOWMULTISELECT) - fileOpenDialog.show(dialogSettings.parentWindow) { + fileOpenDialog.show(dialogSettings.resolveWindowsDialogHandle()) { it.getResults() } } @@ -111,7 +112,7 @@ internal class WindowsFilePicker( fileOpenDialog.setFlag(FOS_PICKFOLDERS) // Show the dialog to the user - fileOpenDialog.show(dialogSettings.parentWindow) { + fileOpenDialog.show(dialogSettings.resolveWindowsDialogHandle()) { it.getResult(SIGDN_DESKTOPABSOLUTEPARSING) } } @@ -143,7 +144,7 @@ internal class WindowsFilePicker( filterExtensions?.let { fileSaveDialog.addFiltersToDialog(it) } // Show the dialog to the user - fileSaveDialog.show(dialogSettings.parentWindow) { + fileSaveDialog.show(dialogSettings.resolveWindowsDialogHandle()) { it.getResult(SIGDN_FILESYSPATH) } } @@ -260,11 +261,11 @@ internal class WindowsFilePicker( } private fun FD.show( - parentWindow: Window?, + parentHandle: Long?, block: (FD) -> T, ): T? { // Show the dialog to the user - val openDialogResult = this.Show(parentWindow.toHwnd()) + val openDialogResult = showWindowsDialog(parentHandle, this::Show) // Valid error code: User canceled the dialog val userCanceledException = Win32Exception(ERROR_CANCELLED) @@ -374,17 +375,17 @@ internal class WindowsFilePicker( } } - private fun Window?.toHwnd(): WinDef.HWND? = when (this) { - null -> { - null + private fun FileKitDialogSettings.resolveWindowsDialogHandle(): Long? = + parent.resolveWindowsHandle { window -> + Pointer.nativeValue(Native.getWindowPointer(window)) } - - else -> { - Native - .getWindowPointer(this) - .let { WinDef.HWND(it) } - } - } } +internal fun showWindowsDialog( + parentHandle: Long?, + show: (WinDef.HWND?) -> T, +): T = show(parentHandle?.let(::toWindowsHwnd)) + +internal fun toWindowsHwnd(handle: Long): WinDef.HWND = WinDef.HWND(Pointer(handle)) + internal fun requiredFileDialogOptions(options: Int): Int = options or FOS_FORCEFILESYSTEM diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt index 3bd71e94..cef7cbac 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgFilePickerPortal.kt @@ -4,6 +4,7 @@ import com.sun.jna.Native import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker +import io.github.vinceglb.filekit.dialogs.resolveXdgPortalParent import io.github.vinceglb.filekit.path import kotlinx.coroutines.CompletableDeferred import org.freedesktop.dbus.DBusMatchRule @@ -21,7 +22,6 @@ import org.freedesktop.dbus.interfaces.Properties import org.freedesktop.dbus.messages.DBusSignal import org.freedesktop.dbus.types.UInt32 import org.freedesktop.dbus.types.Variant -import java.awt.Window import java.io.File import java.lang.reflect.InvocationTargetException import java.net.URI @@ -29,22 +29,10 @@ import java.util.UUID import java.util.concurrent.atomic.AtomicReference // https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.FileChooser.html -internal class XdgFilePickerPortal : PlatformFilePicker { - fun isAvailable(): Boolean { - try { - DBusConnectionBuilder.forSessionBus().build().use { connection -> - connection - .getRemoteObject( - "org.freedesktop.portal.Desktop", - "/org/freedesktop/portal/desktop", - Properties::class.java, - ).Get("org.freedesktop.portal.FileChooser", "version") - return true - } - } catch (_: Exception) { - return false - } - } +internal class XdgFilePickerPortal( + private val transport: XdgFileChooserTransport = DbusXdgFileChooserTransport(), +) : PlatformFilePicker { + fun isAvailable(): Boolean = transport.isAvailable() override suspend fun openFilePicker( fileExtensions: Set?, @@ -54,7 +42,7 @@ internal class XdgFilePickerPortal : PlatformFilePicker { directory = directory, fileExtensions = fileExtensions, title = dialogSettings.title, - parentWindow = dialogSettings.parentWindow, + parentWindow = dialogSettings.resolveXdgPortalParent(), multiple = false, openDirectory = false, )?.firstOrNull() @@ -67,7 +55,7 @@ internal class XdgFilePickerPortal : PlatformFilePicker { directory = directory, fileExtensions = fileExtensions, title = dialogSettings.title, - parentWindow = dialogSettings.parentWindow, + parentWindow = dialogSettings.resolveXdgPortalParent(), multiple = true, openDirectory = false, ) @@ -79,7 +67,7 @@ internal class XdgFilePickerPortal : PlatformFilePicker { directory = directory, fileExtensions = null, title = dialogSettings.title, - parentWindow = dialogSettings.parentWindow, + parentWindow = dialogSettings.resolveXdgPortalParent(), multiple = false, openDirectory = true, )?.firstOrNull() @@ -88,28 +76,22 @@ internal class XdgFilePickerPortal : PlatformFilePicker { directory: PlatformFile?, fileExtensions: Set?, title: String?, - parentWindow: Window?, + parentWindow: String, multiple: Boolean, openDirectory: Boolean, ): List? { - DBusConnectionBuilder.forSessionBus().build().use { connection -> - val handleToken = UUID.randomUUID().toString().replace("-", "") - val options: MutableMap> = HashMap() - options["handle_token"] = Variant(handleToken) - options["multiple"] = Variant(multiple) - options["directory"] = Variant(openDirectory) - fileExtensions?.let { options["filters"] = createFilterOption(it) } - directory?.let { options["current_folder"] = createCurrentFolderOption(it) } - - val deferredResult = registerResponseHandler(connection, handleToken) - getFileChooserObject(connection).OpenFile( - parentWindow = getWindowIdentifier(parentWindow) ?: "", + val options: MutableMap> = HashMap() + options["multiple"] = Variant(multiple) + options["directory"] = Variant(openDirectory) + fileExtensions?.let { options["filters"] = createFilterOption(it) } + directory?.let { options["current_folder"] = createCurrentFolderOption(it) } + + return transport + .openFile( + parentWindow = parentWindow, title = title ?: "", options = options, - ) - val files = deferredResult.await()?.map { File(it) } - return files - } + )?.map { File(it) } } override suspend fun openFileSaver( @@ -119,31 +101,115 @@ internal class XdgFilePickerPortal : PlatformFilePicker { directory: PlatformFile?, dialogSettings: FileKitDialogSettings, ): File? { - DBusConnectionBuilder.forSessionBus().build().use { connection -> - val handleToken = UUID.randomUUID().toString().replace("-", "") - val options: MutableMap> = HashMap() - options["handle_token"] = Variant(handleToken) - - options["current_name"] = when { - defaultExtension != null -> Variant("$suggestedName.$defaultExtension") - else -> Variant(suggestedName) - } + val options: MutableMap> = HashMap() + options["current_name"] = when { + defaultExtension != null -> Variant("$suggestedName.$defaultExtension") + else -> Variant(suggestedName) + } - val filterExtensions = allowedExtensions ?: defaultExtension?.let { setOf(it) } - filterExtensions?.let { options["filters"] = createFilterOption(it) } - directory?.let { options["current_folder"] = createCurrentFolderOption(it) } + val filterExtensions = allowedExtensions ?: defaultExtension?.let { setOf(it) } + filterExtensions?.let { options["filters"] = createFilterOption(it) } + directory?.let { options["current_folder"] = createCurrentFolderOption(it) } - val deferredResult = registerResponseHandler(connection, handleToken) - getFileChooserObject(connection).SaveFile( - parentWindow = getWindowIdentifier(dialogSettings.parentWindow) ?: "", + return transport + .saveFile( + parentWindow = dialogSettings.resolveXdgPortalParent(), title = "", options = options, - ) + )?.first() + ?.let { File(it) } + } + + private fun FileKitDialogSettings.resolveXdgPortalParent(): String = + parent.resolveXdgPortalParent(Native::getWindowID) - return deferredResult.await()?.first()?.let { File(it) } + private fun createFilterOption(extensions: Set): Variant<*> { + val allExtensions = Pair("Supported files", extensions.map { extension -> Pair(0, "*.$extension") }) + val individualExtensions = extensions.map { extension -> Pair(extension, listOf(Pair(0, "*.$extension"))) } + return Variant( + listOf(allExtensions) + individualExtensions, + "a(sa(us))", + ) + } + + private fun createCurrentFolderOption(currentFolder: PlatformFile): Variant<*> { + val stringBytes = currentFolder.path.encodeToByteArray() + val nullTerminated = ByteArray(stringBytes.size + 1) + System.arraycopy(stringBytes, 0, nullTerminated, 0, stringBytes.size) + return Variant(nullTerminated) + } +} + +internal interface XdgFileChooserTransport { + fun isAvailable(): Boolean + + suspend fun openFile( + parentWindow: String, + title: String, + options: MutableMap>, + ): List? + + suspend fun saveFile( + parentWindow: String, + title: String, + options: MutableMap>, + ): List? +} + +private class DbusXdgFileChooserTransport : XdgFileChooserTransport { + override fun isAvailable(): Boolean { + try { + DBusConnectionBuilder.forSessionBus().build().use { connection -> + connection + .getRemoteObject( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + Properties::class.java, + ).Get("org.freedesktop.portal.FileChooser", "version") + return true + } + } catch (_: Exception) { + return false } } + override suspend fun openFile( + parentWindow: String, + title: String, + options: MutableMap>, + ): List? = executeRequest(options) { fileChooser, requestOptions -> + fileChooser.OpenFile( + parentWindow = parentWindow, + title = title, + options = requestOptions, + ) + } + + override suspend fun saveFile( + parentWindow: String, + title: String, + options: MutableMap>, + ): List? = executeRequest(options) { fileChooser, requestOptions -> + fileChooser.SaveFile( + parentWindow = parentWindow, + title = title, + options = requestOptions, + ) + } + + private suspend fun executeRequest( + options: MutableMap>, + request: (FileChooserDbusInterface, MutableMap>) -> Unit, + ): List? = DBusConnectionBuilder.forSessionBus().build().use { connection -> + val handleToken = UUID.randomUUID().toString().replace("-", "") + val requestOptions = HashMap(options) + requestOptions["handle_token"] = Variant(handleToken) + + val deferredResult = registerResponseHandler(connection, handleToken) + request(getFileChooserObject(connection), requestOptions) + deferredResult.await() + } + // https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Request.html private fun registerResponseHandler( connection: DBusConnection, @@ -228,31 +294,11 @@ internal class XdgFilePickerPortal : PlatformFilePicker { } } - // awt only supports X11 - private fun getWindowIdentifier(parentWindow: Window?) = - parentWindow?.let { "X11:${Native.getWindowID(it)}" } - private fun getFileChooserObject(connection: DBusConnection) = connection.getRemoteObject( "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", FileChooserDbusInterface::class.java, ) - - private fun createFilterOption(extensions: Set): Variant<*> { - val allExtensions = Pair("Supported files", extensions.map { extension -> Pair(0, "*.$extension") }) - val individualExtensions = extensions.map { extension -> Pair(extension, listOf(Pair(0, "*.$extension"))) } - return Variant( - listOf(allExtensions) + individualExtensions, - "a(sa(us))", - ) - } - - private fun createCurrentFolderOption(currentFolder: PlatformFile): Variant<*> { - val stringBytes = currentFolder.path.encodeToByteArray() - val nullTerminated = ByteArray(stringBytes.size + 1) - System.arraycopy(stringBytes, 0, nullTerminated, 0, stringBytes.size) - return Variant(nullTerminated) - } } @DBusInterfaceName(value = "org.freedesktop.portal.FileChooser") diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt new file mode 100644 index 00000000..3dfb2a0e --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt @@ -0,0 +1,124 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs + +import org.junit.Assume.assumeFalse +import java.awt.Frame +import java.awt.GraphicsEnvironment +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +class FileKitDialogParentTest { + @Test + fun FileKitDialogParent_windowsWithZero_throwsIllegalArgumentException() { + assertFailsWith { + FileKitDialogParent.windows(0) + } + } + + @Test + fun FileKitDialogParent_windowsWithNegativeBitPattern_preservesHandle() { + val parent = FileKitDialogParent.windows(-1) + + assertEquals(-1, parent.resolveWindowsHandle { error("AWT conversion must not run") }) + } + + @Test + fun FileKitDialogParent_x11WithBoundaryValues_serializesCanonicalPortalParents() { + assertEquals("x11:1", FileKitDialogParent.x11(1).resolveXdgPortalParent { error("unused") }) + assertEquals("x11:2a", FileKitDialogParent.x11(0x2a).resolveXdgPortalParent { error("unused") }) + assertEquals( + "x11:ffffffff", + FileKitDialogParent.x11(0xffff_ffff).resolveXdgPortalParent { error("unused") }, + ) + } + + @Test + fun FileKitDialogParent_x11OutsideUnsigned32BitRange_throwsIllegalArgumentException() { + listOf(-1L, 0L, 0x1_0000_0000L).forEach { xid -> + assertFailsWith { + FileKitDialogParent.x11(xid) + } + } + } + + @Test + fun FileKitDialogParent_waylandWithOpaqueValue_preservesEveryCharacter() { + listOf( + "xdg-foreign-token", + " token with spaces ", + "12345", + "0xdeadbeef", + "wayland:already-prefixed", + ).forEach { token -> + assertEquals( + "wayland:$token", + FileKitDialogParent.wayland(token).resolveXdgPortalParent { error("unused") }, + ) + } + } + + @Test + fun FileKitDialogParent_waylandWithEmptyOrNul_throwsIllegalArgumentException() { + listOf("", "before\u0000after").forEach { token -> + assertFailsWith { + FileKitDialogParent.wayland(token) + } + } + } + + @Test + fun FileKitDialogParent_withNativeValue_redactsDiagnosticString() { + assertEquals("FileKitDialogParent.Windows", FileKitDialogParent.windows(0x1234).toString()) + assertEquals("FileKitDialogParent.X11", FileKitDialogParent.x11(0x5678).toString()) + assertEquals("FileKitDialogParent.Wayland", FileKitDialogParent.wayland("secret-token").toString()) + } + + @Test + fun FileKitDialogParent_withIncompatibleAdapter_throwsPickerExceptionWithoutRawValue() { + val error = assertFailsWith { + FileKitDialogParent.windows(0x1234).resolveXdgPortalParent { error("unused") } + } + + assertNotEquals(true, error.message?.contains("1234")) + } + + @Test + fun FileKitDialogParent_withoutParent_resolvesAsUnparented() { + val parent: FileKitDialogParent? = null + + assertNull(parent.resolveWindowsHandle { error("unused") }) + assertEquals("", parent.resolveXdgPortalParent { error("unused") }) + } + + @Test + fun AwtNativeIdentifier_withZeroOrException_throwsPickerException() { + assertFailsWith { + resolveAwtNativeIdentifier("Windows HWND") { 0 } + } + + val cause = IllegalStateException("Component must be displayable") + val error = assertFailsWith { + resolveAwtNativeIdentifier("X11 XID") { throw cause } + } + + assertSame(cause, error.cause) + } + + @Test + fun FileKitDialogParent_awtWithSameWindow_hasStructuralEquality() { + assumeFalse(GraphicsEnvironment.isHeadless()) + val frame = Frame() + + try { + assertEquals(FileKitDialogParent.awt(frame), FileKitDialogParent.awt(frame)) + assertEquals("FileKitDialogParent.Awt", FileKitDialogParent.awt(frame).toString()) + } finally { + frame.dispose() + } + } +} diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettingsTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettingsTest.kt new file mode 100644 index 00000000..fdd83c53 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogSettingsTest.kt @@ -0,0 +1,45 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +class FileKitDialogSettingsTest { + @Test + fun FileKitDialogSettings_defaultValues_hasNoParent() { + assertNull(FileKitDialogSettings().parent) + assertNull(FileKitDialogSettings.createDefault().parent) + } + + @Test + fun FileKitDialogSettings_withNativeParent_hasDataClassValueBehavior() { + val macOS = FileKitMacOSSettings() + val parent = FileKitDialogParent.windows(42) + val first = FileKitDialogSettings(title = "Choose", parent = parent, macOS = macOS) + val second = FileKitDialogSettings(title = "Choose", parent = parent, macOS = macOS) + + assertEquals(first, second) + assertEquals(first.hashCode(), second.hashCode()) + assertSame(parent, first.component2()) + } + + @Test + fun FileKitDialogSettings_copyingOtherValues_preservesOrClearsParent() { + val parent = FileKitDialogParent.x11(42) + val settings = FileKitDialogSettings(parent = parent) + + assertSame(parent, settings.copy(title = "Choose").parent) + assertNull(settings.copy(parent = null).parent) + } + + @Test + fun FileKitDialogSettings_toString_redactsNativeParentValue() { + val rendered = FileKitDialogSettings(parent = FileKitDialogParent.windows(0x1234)).toString() + + assertEquals(true, rendered.contains("FileKitDialogParent.Windows")) + assertEquals(false, rendered.contains("1234")) + } +} diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogOwnerTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogOwnerTest.kt new file mode 100644 index 00000000..67156dc3 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/awt/AwtDialogOwnerTest.kt @@ -0,0 +1,52 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.platform.awt + +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitPickerException +import kotlinx.coroutines.test.runTest +import java.awt.Dialog +import java.awt.Frame +import java.awt.Window +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AwtDialogOwnerTest { + @Test + fun isSupportedAwtFileDialogOwner_withOwnerClasses_matchesFileDialogConstructors() { + assertTrue(isSupportedAwtFileDialogOwner(Frame::class.java)) + assertTrue(isSupportedAwtFileDialogOwner(Dialog::class.java)) + assertFalse(isSupportedAwtFileDialogOwner(Window::class.java)) + } + + @Test + fun AwtFilePicker_withNativeParent_failsBeforeOpeningUi() = runTest { + assertFailsWith { + AwtFilePicker().openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = FileKitDialogSettings( + parent = FileKitDialogParent.windows(42), + ), + ) + } + } + + @Test + fun AwtFileSaver_withNativeParent_failsBeforeOpeningUi() = runTest { + assertFailsWith { + AwtFileSaver.saveFile( + suggestedName = "example", + defaultExtension = "txt", + allowedExtensions = null, + directory = null, + dialogSettings = FileKitDialogSettings( + parent = FileKitDialogParent.x11(42), + ), + ) + } + } +} diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/linux/LinuxFilePickerTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/linux/LinuxFilePickerTest.kt new file mode 100644 index 00000000..5a2cb35f --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/linux/LinuxFilePickerTest.kt @@ -0,0 +1,128 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.platform.linux + +import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import io.github.vinceglb.filekit.dialogs.FileKitPickerException +import io.github.vinceglb.filekit.dialogs.platform.PlatformFilePicker +import io.github.vinceglb.filekit.dialogs.platform.awt.AwtFilePicker +import io.github.vinceglb.filekit.dialogs.platform.swing.SwingFilePicker +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class LinuxFilePickerTest { + @Test + fun LinuxFilePicker_withAvailablePortal_routesEveryOperationToPortal() = runTest { + val portal = RecordingFilePicker("portal") + val awt = RecordingFilePicker("awt") + val swing = RecordingFilePicker("swing") + var availabilityChecks = 0 + val picker = LinuxFilePicker(portal, awt, swing) { + availabilityChecks += 1 + true + } + + callEveryOperation(picker) + + assertEquals(listOf("open", "multi", "directory", "save"), portal.operations) + assertEquals(emptyList(), awt.operations) + assertEquals(emptyList(), swing.operations) + assertEquals(1, availabilityChecks) + } + + @Test + fun LinuxFilePicker_withoutAvailablePortal_routesToAwtAndSwingFallbacks() = runTest { + val portal = RecordingFilePicker("portal") + val awt = RecordingFilePicker("awt") + val swing = RecordingFilePicker("swing") + val picker = LinuxFilePicker(portal, awt, swing) { false } + + callEveryOperation(picker) + + assertEquals(emptyList(), portal.operations) + assertEquals(listOf("open", "multi", "save"), awt.operations) + assertEquals(listOf("directory"), swing.operations) + } + + @Test + fun LinuxFilePicker_withoutAvailablePortal_rejectsNativeParentsOnEveryFallback() = runTest { + val picker = LinuxFilePicker( + xdgFilePickerPortal = RecordingFilePicker("portal"), + awtFilePicker = AwtFilePicker(), + swingFilePicker = SwingFilePicker(), + isXdgFilePickerPortalAvailable = { false }, + ) + val settings = FileKitDialogSettings(parent = FileKitDialogParent.x11(42)) + + assertFailsWith { + picker.openFilePicker(null, null, settings) + } + assertFailsWith { + picker.openFilesPicker(null, null, settings) + } + assertFailsWith { + picker.openDirectoryPicker(null, settings) + } + assertFailsWith { + picker.openFileSaver("example", "txt", null, null, settings) + } + } + + private suspend fun callEveryOperation(picker: PlatformFilePicker) { + val settings = FileKitDialogSettings() + picker.openFilePicker(null, null, settings) + picker.openFilesPicker(null, null, settings) + picker.openDirectoryPicker(null, settings) + picker.openFileSaver("example", "txt", null, null, settings) + } +} + +private class RecordingFilePicker( + private val name: String, +) : PlatformFilePicker { + val operations = mutableListOf() + + override suspend fun openFilePicker( + fileExtensions: Set?, + directory: PlatformFile?, + dialogSettings: FileKitDialogSettings, + ): File? { + operations += "open" + return null + } + + override suspend fun openFilesPicker( + fileExtensions: Set?, + directory: PlatformFile?, + dialogSettings: FileKitDialogSettings, + ): List? { + operations += "multi" + return null + } + + override suspend fun openDirectoryPicker( + directory: PlatformFile?, + dialogSettings: FileKitDialogSettings, + ): File? { + operations += "directory" + return null + } + + override suspend fun openFileSaver( + suggestedName: String, + defaultExtension: String?, + allowedExtensions: Set?, + directory: PlatformFile?, + dialogSettings: FileKitDialogSettings, + ): File? { + operations += "save" + return null + } + + override fun toString(): String = name +} diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogParentTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogParentTest.kt new file mode 100644 index 00000000..ab23e484 --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/windows/WindowsDialogParentTest.kt @@ -0,0 +1,35 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.platform.windows + +import com.sun.jna.Pointer +import com.sun.jna.platform.win32.WinDef +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WindowsDialogParentTest { + @Test + fun showWindowsDialog_withOwnerHandle_forwardsExactHwnd() { + var shownParent: WinDef.HWND? = null + + val result = showWindowsDialog(-1) { hwnd -> + shownParent = hwnd + "shown" + } + + assertEquals("shown", result) + assertEquals(-1, Pointer.nativeValue(shownParent?.pointer)) + } + + @Test + fun showWindowsDialog_withoutOwnerHandle_forwardsNull() { + var shownParent: WinDef.HWND? = WinDef.HWND(Pointer(1)) + + showWindowsDialog(parentHandle = null) { hwnd -> + shownParent = hwnd + } + + assertNull(shownParent) + } +} diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgDialogParentTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgDialogParentTest.kt new file mode 100644 index 00000000..9cd3b8bc --- /dev/null +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/platform/xdg/XdgDialogParentTest.kt @@ -0,0 +1,80 @@ +@file:Suppress("ktlint:standard:function-naming", "FunctionName") + +package io.github.vinceglb.filekit.dialogs.platform.xdg + +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import kotlinx.coroutines.test.runTest +import org.freedesktop.dbus.types.Variant +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertEquals + +class XdgDialogParentTest { + @Test + fun XdgFilePickerPortal_openAndSave_withX11Parent_forwardsCanonicalParent() = runTest { + val transport = RecordingXdgFileChooserTransport() + val picker = XdgFilePickerPortal(transport) + val settings = FileKitDialogSettings( + title = "Choose", + parent = FileKitDialogParent.x11(0x2a), + ) + + picker.openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = settings, + ) + picker.openFileSaver( + suggestedName = "example", + defaultExtension = "txt", + allowedExtensions = null, + directory = null, + dialogSettings = settings, + ) + + assertEquals("x11:2a", transport.openParent) + assertEquals("x11:2a", transport.saveParent) + } + + @Test + fun XdgFilePickerPortal_open_withWaylandParent_forwardsOpaqueParent() = runTest { + val transport = RecordingXdgFileChooserTransport() + val picker = XdgFilePickerPortal(transport) + + picker.openFilePicker( + fileExtensions = null, + directory = null, + dialogSettings = FileKitDialogSettings( + parent = FileKitDialogParent.wayland("token with spaces"), + ), + ) + + assertEquals("wayland:token with spaces", transport.openParent) + } +} + +private class RecordingXdgFileChooserTransport : XdgFileChooserTransport { + var openParent: String? = null + var saveParent: String? = null + + override fun isAvailable(): Boolean = true + + override suspend fun openFile( + parentWindow: String, + title: String, + options: MutableMap>, + ): List? { + openParent = parentWindow + return null + } + + override suspend fun saveFile( + parentWindow: String, + title: String, + options: MutableMap>, + ): List? { + saveParent = parentWindow + return null + } +} From f8efc5553a610a8da250480f4f8578708c9e8460 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Thu, 30 Jul 2026 17:42:53 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=93=9D=20Document=20and=20demonstrate?= =?UTF-8?q?=20native=20JVM=20parents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dialogs/dialog-settings.mdx | 98 ++++++++++++++++--- sample/nucleusApp/build.gradle.kts | 1 + .../vinceglb/filekit/sample/nucleus/Main.kt | 29 +++++- .../vinceglb/filekit/sample/shared/App.kt | 7 +- .../sample/shared/navigation/AppNavigation.kt | 3 + .../ui/screens/filepicker/FilePickerScreen.kt | 14 ++- .../FilePickerDialogSettings.jvm.kt | 1 - 7 files changed, 134 insertions(+), 19 deletions(-) diff --git a/docs/dialogs/dialog-settings.mdx b/docs/dialogs/dialog-settings.mdx index 39d9de0b..f57be924 100644 --- a/docs/dialogs/dialog-settings.mdx +++ b/docs/dialogs/dialog-settings.mdx @@ -11,20 +11,13 @@ FileKit allows you to customize dialog behavior with platform-specific settings. On JVM platforms (Windows, macOS, Linux), you can customize: - `title`: Set a custom title for the dialog -- `parentWindow`: Set the parent window for the dialog (useful for modal behavior) +- `parent`: Set the typed parent window identity used for modality and stacking - `macOS`: Configure macOS-specific settings ```kotlin -// Basic settings with title and parent window val settings = FileKitDialogSettings( title = "Select a file", - parentWindow = window -) - -// With macOS-specific settings -val settings = FileKitDialogSettings( - title = "Select a file", - parentWindow = window, + parent = FileKitDialogParent.awt(window), macOS = FileKitMacOSSettings( resolvesAliases = false, canCreateDirectories = true @@ -32,15 +25,96 @@ val settings = FileKitDialogSettings( ) ``` -For desktop applications, it's recommended to pass the window reference: +`FileKitDialogParent` represents one canonical parent, with factories for each +supported JVM window system: + +```kotlin +FileKitDialogParent.awt(window) +FileKitDialogParent.windows(hwnd) +FileKitDialogParent.x11(xid) +FileKitDialogParent.wayland(exportedHandle) +``` + +The parent is borrowed. Keep the AWT window, HWND, X11 window, or Wayland export +alive until the suspending picker call completes. FileKit never takes ownership +or extends its lifetime. + +| Active JVM picker | Accepted parents | Notes | +| --- | --- | --- | +| Windows | AWT, Windows HWND, or no parent | An AWT window is resolved to an HWND when the dialog opens. | +| Linux XDG portal | AWT, X11 XID, Wayland export, or no parent | X11 identifiers are sent as lowercase hexadecimal. | +| Linux AWT fallback | AWT `Frame`/`Dialog`, or no parent | A native X11 or Wayland parent fails if the portal is unavailable; it is not silently dropped. | +| Linux Swing directory fallback | Any AWT `Window`, or no parent | Native X11 and Wayland parents are not compatible. | +| macOS on JVM | AWT, or no parent | The current `runModal()` implementation is application-modal and does not establish a window-modal sheet. | + + +`wayland()` accepts the unprefixed opaque handle exported through +`xdg_foreign`. FileKit adds the `wayland:` portal prefix without trimming or +normalizing the value. A raw `wl_surface*`, a Tao handle, and a string that you +invented from a pointer are not exported Wayland handles. + + +Invalid factory arguments, such as a zero HWND, an out-of-range XID, or an empty +Wayland handle, throw `IllegalArgumentException`. A valid parent that the active +picker cannot use, or an AWT parent that cannot resolve to a native identifier, +throws `FileKitPickerException` before opening the dialog. + +#### Compose Desktop + +The `WindowScope.remember*Launcher` extensions automatically use the scope's AWT +window and replace any parent already present in the supplied settings. For a +plain launcher or direct picker call, provide it explicitly: ```kotlin Window(onCloseRequest = ::exitApplication) { - val dialogSettings = FileKitDialogSettings(this.window) + val dialogSettings = FileKitDialogSettings( + parent = FileKitDialogParent.awt(this.window) + ) App(dialogSettings) } ``` +#### Migrating from FileKit 0.14 + +The JVM settings API intentionally changed in FileKit 0.15. Replace: + +```kotlin +FileKitDialogSettings(parentWindow = window) +``` + +with: + +```kotlin +FileKitDialogSettings(parent = FileKitDialogParent.awt(window)) +``` + +There is no compatibility `parentWindow` property or constructor. + +#### Nucleus with the Tao backend + +Nucleus 2.1.10 exposes a native HWND for Tao windows on Windows. Adapt that +framework-specific window locally and pass the resulting settings to a plain, +non-`WindowScope` launcher: + +```kotlin +val parent = when (Platform.Current) { + Platform.Windows -> nucleusWindow.unsafe.taoWindow + ?.nativeHandle + ?.takeIf { it != 0L } + ?.let(FileKitDialogParent::windows) + else -> null +} + +val settings = FileKitDialogSettings(parent = parent) +``` + +Do not pass `nucleusWindow.unsafe.taoHandle`: it is an opaque Tao event-loop +identity, not an operating-system dialog parent. Nucleus currently does not +expose an X11 XID or `xdg_foreign` export for Tao on Linux. On macOS it exposes +an NSView, while FileKit's JVM picker would need a separate NSWindow sheet +implementation. Tao parenting is therefore currently supported through this +adapter on Windows only. + ### iOS and macOS Settings On iOS and macOS, you can configure: @@ -91,7 +165,7 @@ expect fun createDialogSettings(): FileKitDialogSettings actual fun createDialogSettings(): FileKitDialogSettings { return FileKitDialogSettings( title = "Select a file", - parentWindow = window, + parent = FileKitDialogParent.awt(window), macOS = FileKitMacOSSettings( canCreateDirectories = true ) diff --git a/sample/nucleusApp/build.gradle.kts b/sample/nucleusApp/build.gradle.kts index bab4fc3a..ccaea4ef 100644 --- a/sample/nucleusApp/build.gradle.kts +++ b/sample/nucleusApp/build.gradle.kts @@ -30,4 +30,5 @@ dependencies { // FileKit implementation(projects.filekitCore) + implementation(projects.filekitDialogs) } diff --git a/sample/nucleusApp/src/main/kotlin/io/github/vinceglb/filekit/sample/nucleus/Main.kt b/sample/nucleusApp/src/main/kotlin/io/github/vinceglb/filekit/sample/nucleus/Main.kt index 73be04d3..7be64d18 100644 --- a/sample/nucleusApp/src/main/kotlin/io/github/vinceglb/filekit/sample/nucleus/Main.kt +++ b/sample/nucleusApp/src/main/kotlin/io/github/vinceglb/filekit/sample/nucleus/Main.kt @@ -5,8 +5,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.rememberWindowState import dev.nucleusframework.application.DecoratedWindow import dev.nucleusframework.application.NucleusBackend +import dev.nucleusframework.application.NucleusWindow import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.core.runtime.Platform import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent import io.github.vinceglb.filekit.sample.shared.App fun main() = nucleusApplication(backend = NucleusBackend.Tao) { @@ -19,6 +22,30 @@ fun main() = nucleusApplication(backend = NucleusBackend.Tao) { title = "FileKit Nucleus Sample", onCloseRequest = ::exitApplication, ) { - App() + val dialogParent = nucleusWindow.fileKitDialogParent() + App( + dialogSettingsTransform = { settings -> + settings.copy(parent = dialogParent) + }, + ) + } +} + +private fun NucleusWindow.fileKitDialogParent(): FileKitDialogParent? { + unsafe.awtWindow?.let { return FileKitDialogParent.awt(it) } + + return when (Platform.Current) { + Platform.Windows -> { + unsafe.taoWindow + ?.nativeHandle + ?.takeIf { it != 0L } + ?.let(FileKitDialogParent::windows) + } + + // Tao does not expose an XDG portal identifier on Linux, and its macOS + // handle is an NSView rather than the NSWindow required for a sheet. + else -> { + null + } } } diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/App.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/App.kt index 0e8a041a..c6a44c7e 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/App.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/App.kt @@ -5,11 +5,15 @@ import androidx.compose.ui.Modifier import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory import io.github.vinceglb.filekit.coil.addPlatformFileSupport +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.sample.shared.navigation.AppNavigation import io.github.vinceglb.filekit.sample.shared.ui.theme.AppTheme @Composable -public fun App(modifier: Modifier = Modifier) { +public fun App( + modifier: Modifier = Modifier, + dialogSettingsTransform: (FileKitDialogSettings) -> FileKitDialogSettings = { it }, +) { setSingletonImageLoaderFactory { context -> ImageLoader .Builder(context) @@ -21,6 +25,7 @@ public fun App(modifier: Modifier = Modifier) { AppTheme { AppNavigation( modifier = modifier, + dialogSettingsTransform = dialogSettingsTransform, ) } } diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt index 881458ed..7bb5fc47 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt @@ -13,6 +13,7 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import androidx.savedstate.serialization.SavedStateConfiguration import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.sample.shared.ui.screens.bookmarks.BookmarksRoute import io.github.vinceglb.filekit.sample.shared.ui.screens.camerapicker.CameraPickerRoute import io.github.vinceglb.filekit.sample.shared.ui.screens.debug.DebugRoute @@ -63,6 +64,7 @@ private data class FileDetails( @Composable internal fun AppNavigation( modifier: Modifier = Modifier, + dialogSettingsTransform: (FileKitDialogSettings) -> FileKitDialogSettings = { it }, ) { val backStack = rememberNavBackStack( configuration = SavedStateConfiguration { @@ -113,6 +115,7 @@ internal fun AppNavigation( onDisplayFileDetails = { file -> backStack.add(FileDetails(file)) }, + dialogSettingsTransform = dialogSettingsTransform, ) } entry { diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt index c510389a..4cc31d2b 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.tooling.preview.AndroidUiModes import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.FileKitMode import io.github.vinceglb.filekit.dialogs.FileKitPickerState import io.github.vinceglb.filekit.dialogs.FileKitType @@ -56,10 +57,12 @@ import io.github.vinceglb.filekit.sample.shared.util.plus internal fun FilePickerRoute( onNavigateBack: () -> Unit, onDisplayFileDetails: (file: PlatformFile) -> Unit, + dialogSettingsTransform: (FileKitDialogSettings) -> FileKitDialogSettings = { it }, ) { FilePickerScreen( onNavigateBack = onNavigateBack, onDisplayFileDetails = onDisplayFileDetails, + dialogSettingsTransform = dialogSettingsTransform, ) } @@ -68,6 +71,7 @@ internal fun FilePickerRoute( private fun FilePickerScreen( onNavigateBack: () -> Unit, onDisplayFileDetails: (file: PlatformFile) -> Unit, + dialogSettingsTransform: (FileKitDialogSettings) -> FileKitDialogSettings = { it }, ) { var buttonState by remember { mutableStateOf(AppScreenHeaderButtonState.Enabled) } var pickerMode by remember { mutableStateOf(Modes.Single) } @@ -77,9 +81,11 @@ private fun FilePickerScreen( var files by remember { mutableStateOf(emptyList()) } val dialogSettingsState = rememberFilePickerDialogSettingsState() + val dialogSettings = dialogSettingsTransform(dialogSettingsState.build()) val startDirectoryLauncher = rememberDirectoryPickerLauncher( directory = startDirectory, + dialogSettings = dialogSettings, ) { directory -> if (directory != null) { startDirectory = directory @@ -92,7 +98,7 @@ private fun FilePickerScreen( type = resolvedType, mode = FileKitMode.Single, directory = startDirectory, - dialogSettings = dialogSettingsState.build(), + dialogSettings = dialogSettings, ) { selectedFile -> buttonState = AppScreenHeaderButtonState.Enabled files = selectedFile?.let(::listOf) ?: emptyList() @@ -102,7 +108,7 @@ private fun FilePickerScreen( type = resolvedType, mode = FileKitMode.Multiple(maxItems = pickerMaxItems), directory = startDirectory, - dialogSettings = dialogSettingsState.build(), + dialogSettings = dialogSettings, ) { selectedFiles -> buttonState = AppScreenHeaderButtonState.Enabled files = selectedFiles ?: emptyList() @@ -112,7 +118,7 @@ private fun FilePickerScreen( type = resolvedType, mode = FileKitMode.SingleWithState, directory = startDirectory, - dialogSettings = dialogSettingsState.build(), + dialogSettings = dialogSettings, ) { state -> buttonState = AppScreenHeaderButtonState.Enabled files = when (state) { @@ -128,7 +134,7 @@ private fun FilePickerScreen( type = resolvedType, mode = FileKitMode.MultipleWithState(maxItems = pickerMaxItems), directory = startDirectory, - dialogSettings = dialogSettingsState.build(), + dialogSettings = dialogSettings, ) { state -> buttonState = AppScreenHeaderButtonState.Enabled files = when (state) { diff --git a/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerDialogSettings.jvm.kt b/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerDialogSettings.jvm.kt index acd73f6c..a38dbaa6 100644 --- a/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerDialogSettings.jvm.kt +++ b/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filepicker/FilePickerDialogSettings.jvm.kt @@ -31,7 +31,6 @@ internal class JvmFilePickerDialogSettingsState : FilePickerDialogSettingsState override fun build(): FileKitDialogSettings = FileKitDialogSettings( title = title.ifBlank { null }, - parentWindow = null, macOS = FileKitMacOSSettings( resolvesAliases = resolvesAliases, canCreateDirectories = canCreateDirectories, From 4b0b054ce715c01d7fdeccf584fd1856056e5602 Mon Sep 17 00:00:00 2001 From: vinceglb Date: Thu, 30 Jul 2026 18:06:19 +0200 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20Enforce=20dialog=20parent=20?= =?UTF-8?q?invariants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dialogs/FileKitDialogParent.jvm.kt | 142 +++++++++--------- .../dialogs/FileKitDialogParentTest.kt | 14 ++ .../sample/shared/navigation/AppNavigation.kt | 2 + .../directorypicker/DirectoryPickerScreen.kt | 7 + .../ui/screens/filesaver/FileSaverLauncher.kt | 2 + .../ui/screens/filesaver/FileSaverScreen.kt | 10 +- .../filesaver/FileSaverLauncher.jvm.kt | 3 +- .../filesaver/FileSaverLauncher.macos.kt | 3 +- .../filesaver/FileSaverLauncher.mobile.kt | 3 +- .../filesaver/FileSaverLauncher.web.kt | 2 + 10 files changed, 116 insertions(+), 72 deletions(-) diff --git a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt index 19c2f8e2..635f558f 100644 --- a/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt +++ b/filekit-dialogs/src/jvmMain/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParent.jvm.kt @@ -13,25 +13,25 @@ import java.awt.Window * with an identifier from another window system. */ public sealed class FileKitDialogParent { - internal data class Awt( + private data class Awt( internal val window: Window, ) : FileKitDialogParent() { override fun toString(): String = "FileKitDialogParent.Awt" } - internal data class Windows( + private data class Windows( internal val hwnd: Long, ) : FileKitDialogParent() { override fun toString(): String = "FileKitDialogParent.Windows" } - internal data class X11( + private data class X11( internal val xid: Long, ) : FileKitDialogParent() { override fun toString(): String = "FileKitDialogParent.X11" } - internal data class Wayland( + private data class Wayland( internal val exportedHandle: String, ) : FileKitDialogParent() { override fun toString(): String = "FileKitDialogParent.Wayland" @@ -81,77 +81,97 @@ public sealed class FileKitDialogParent { return Wayland(exportedHandle) } } -} -internal fun FileKitDialogParent?.resolveWindowsHandle( - awtWindowHandle: (Window) -> Long, -): Long? = when (this) { - null -> null + internal fun resolveWindowsHandleValue( + awtWindowHandle: (Window) -> Long, + ): Long = when (this) { + is Awt -> resolveAwtNativeIdentifier("Windows HWND") { + awtWindowHandle(window) + } + + is Windows -> hwnd - is FileKitDialogParent.Awt -> resolveAwtNativeIdentifier("Windows HWND") { - awtWindowHandle(window) + else -> unsupportedParent( + adapter = "Windows dialogs", + supported = "AWT or Windows", + ) } - is FileKitDialogParent.Windows -> hwnd + internal fun resolveXdgPortalParentValue( + awtWindowXid: (Window) -> Long, + ): String = when (this) { + is Awt -> { + val xid = resolveAwtNativeIdentifier("X11 XID") { + awtWindowXid(window) + } + x11PortalParent(xid) + } - else -> unsupportedParent( - adapter = "Windows dialogs", - supported = "AWT or Windows", - ) -} + is X11 -> { + x11PortalParent(xid) + } -internal fun FileKitDialogParent?.resolveXdgPortalParent( - awtWindowXid: (Window) -> Long, -): String = when (this) { - null -> { - "" - } + is Wayland -> { + "wayland:$exportedHandle" + } - is FileKitDialogParent.Awt -> { - val xid = resolveAwtNativeIdentifier("X11 XID") { - awtWindowXid(window) + else -> { + unsupportedParent( + adapter = "XDG portal dialogs", + supported = "AWT, X11, or Wayland", + ) } - x11PortalParent(xid) } - is FileKitDialogParent.X11 -> { - x11PortalParent(xid) + internal fun requireAwtWindow(adapter: String): Window = when (this) { + is Awt -> window + + else -> unsupportedParent( + adapter = adapter, + supported = "AWT", + ) } - is FileKitDialogParent.Wayland -> { - "wayland:$exportedHandle" + internal fun requireMacOSCompatibility() { + when (this) { + is Awt -> Unit + + else -> unsupportedParent( + adapter = "macOS dialogs", + supported = "AWT", + ) + } } - else -> { - unsupportedParent( - adapter = "XDG portal dialogs", - supported = "AWT, X11, or Wayland", - ) + private fun unsupportedParent( + adapter: String, + supported: String, + ): Nothing = throw FileKitPickerException( + "$adapter does not support ${kindName()} dialog parents. Supported parents: $supported.", + ) + + private fun kindName(): String = when (this) { + is Awt -> "AWT" + is Windows -> "Windows" + is X11 -> "X11" + is Wayland -> "Wayland" } } -internal fun FileKitDialogParent?.requireAwtWindowOrNull( - adapter: String, -): Window? = when (this) { - null -> null +internal fun FileKitDialogParent?.resolveWindowsHandle( + awtWindowHandle: (Window) -> Long, +): Long? = this?.resolveWindowsHandleValue(awtWindowHandle) - is FileKitDialogParent.Awt -> window +internal fun FileKitDialogParent?.resolveXdgPortalParent( + awtWindowXid: (Window) -> Long, +): String = this?.resolveXdgPortalParentValue(awtWindowXid) ?: "" - else -> unsupportedParent( - adapter = adapter, - supported = "AWT", - ) -} +internal fun FileKitDialogParent?.requireAwtWindowOrNull( + adapter: String, +): Window? = this?.requireAwtWindow(adapter) internal fun FileKitDialogParent?.requireMacOSCompatible() { - when (this) { - null, is FileKitDialogParent.Awt -> Unit - - else -> unsupportedParent( - adapter = "macOS dialogs", - supported = "AWT", - ) - } + this?.requireMacOSCompatibility() } internal fun resolveAwtNativeIdentifier( @@ -178,18 +198,4 @@ internal fun resolveAwtNativeIdentifier( private fun x11PortalParent(xid: Long): String = "x11:${xid.toString(16)}" -private fun FileKitDialogParent.unsupportedParent( - adapter: String, - supported: String, -): Nothing = throw FileKitPickerException( - "$adapter does not support ${kindName()} dialog parents. Supported parents: $supported.", -) - -private fun FileKitDialogParent.kindName(): String = when (this) { - is FileKitDialogParent.Awt -> "AWT" - is FileKitDialogParent.Windows -> "Windows" - is FileKitDialogParent.X11 -> "X11" - is FileKitDialogParent.Wayland -> "Wayland" -} - private const val X11_XID_MAX: Long = 0xffff_ffffL diff --git a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt index 3dfb2a0e..bbf9e591 100644 --- a/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt +++ b/filekit-dialogs/src/jvmTest/kotlin/io/github/vinceglb/filekit/dialogs/FileKitDialogParentTest.kt @@ -5,12 +5,14 @@ package io.github.vinceglb.filekit.dialogs import org.junit.Assume.assumeFalse import java.awt.Frame import java.awt.GraphicsEnvironment +import java.lang.reflect.Modifier import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertSame +import kotlin.test.assertTrue class FileKitDialogParentTest { @Test @@ -78,6 +80,18 @@ class FileKitDialogParentTest { assertEquals("FileKitDialogParent.Wayland", FileKitDialogParent.wayland("secret-token").toString()) } + @Test + fun FileKitDialogParent_concreteVariants_arePrivateInJvmBytecode() { + val variantNames = setOf("Awt", "Windows", "X11", "Wayland") + val variants = FileKitDialogParent::class.java.declaredClasses + .filter { it.simpleName in variantNames } + + assertEquals(variantNames, variants.map { it.simpleName }.toSet()) + variants.forEach { variant -> + assertTrue(Modifier.isPrivate(variant.modifiers), "${variant.simpleName} must remain private") + } + } + @Test fun FileKitDialogParent_withIncompatibleAdapter_throwsPickerExceptionWithoutRawValue() { val error = assertFailsWith { diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt index 7bb5fc47..3defae4c 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/navigation/AppNavigation.kt @@ -156,6 +156,7 @@ internal fun AppNavigation( onDisplayFileDetails = { file -> backStack.add(FileDetails(file)) }, + dialogSettingsTransform = dialogSettingsTransform, ) } entry { @@ -164,6 +165,7 @@ internal fun AppNavigation( onDisplayFileDetails = { file -> backStack.add(FileDetails(file)) }, + dialogSettingsTransform = dialogSettingsTransform, ) } entry { diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/directorypicker/DirectoryPickerScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/directorypicker/DirectoryPickerScreen.kt index d1d922f3..6cda39aa 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/directorypicker/DirectoryPickerScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/directorypicker/DirectoryPickerScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.tooling.preview.AndroidUiModes import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.name import io.github.vinceglb.filekit.sample.shared.ui.components.AppDottedBorderCard @@ -47,10 +48,12 @@ import io.github.vinceglb.filekit.sample.shared.util.plus internal fun DirectoryPickerRoute( onNavigateBack: () -> Unit, onDisplayFileDetails: (file: PlatformFile) -> Unit, + dialogSettingsTransform: (FileKitDialogSettings) -> FileKitDialogSettings = { it }, ) { DirectoryPickerScreen( onNavigateBack = onNavigateBack, onDisplayFileDetails = onDisplayFileDetails, + dialogSettingsTransform = dialogSettingsTransform, ) } @@ -59,14 +62,17 @@ internal fun DirectoryPickerRoute( private fun DirectoryPickerScreen( onNavigateBack: () -> Unit, onDisplayFileDetails: (file: PlatformFile) -> Unit, + dialogSettingsTransform: (FileKitDialogSettings) -> FileKitDialogSettings = { it }, ) { var buttonState by remember { mutableStateOf(AppScreenHeaderButtonState.Enabled) } var startDirectory by remember { mutableStateOf(null) } var pickedDirectories by remember { mutableStateOf(emptyList()) } var selectedFile by remember { mutableStateOf(null) } + val dialogSettings = dialogSettingsTransform(FileKitDialogSettings.createDefault()) val directoryLauncher = rememberDirectoryPickerLauncher( directory = startDirectory, + dialogSettings = dialogSettings, ) { directory -> buttonState = AppScreenHeaderButtonState.Enabled if (directory != null) { @@ -75,6 +81,7 @@ private fun DirectoryPickerScreen( } val startDirectoryLauncher = rememberDirectoryPickerLauncher( directory = startDirectory, + dialogSettings = dialogSettings, ) { directory -> if (directory != null) { startDirectory = directory diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.kt index 6a8f3070..245b95e0 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.kt @@ -2,6 +2,7 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.filesaver import androidx.compose.runtime.Composable import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings internal interface FileSaverLauncher { val isSupported: Boolean @@ -16,5 +17,6 @@ internal interface FileSaverLauncher { @Composable internal expect fun rememberFileSaverLauncher( + dialogSettings: FileKitDialogSettings, onResult: (PlatformFile?) -> Unit, ): FileSaverLauncher diff --git a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverScreen.kt b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverScreen.kt index bfcc1b51..393351e3 100644 --- a/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverScreen.kt +++ b/sample/shared/src/commonMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.tooling.preview.AndroidUiModes import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings import io.github.vinceglb.filekit.dialogs.compose.rememberDirectoryPickerLauncher import io.github.vinceglb.filekit.name import io.github.vinceglb.filekit.sample.shared.ui.components.AppDottedBorderCard @@ -48,10 +49,12 @@ import io.github.vinceglb.filekit.sample.shared.util.plus internal fun FileSaverRoute( onNavigateBack: () -> Unit, onDisplayFileDetails: (file: PlatformFile) -> Unit, + dialogSettingsTransform: (FileKitDialogSettings) -> FileKitDialogSettings = { it }, ) { FileSaverScreen( onNavigateBack = onNavigateBack, onDisplayFileDetails = onDisplayFileDetails, + dialogSettingsTransform = dialogSettingsTransform, ) } @@ -60,6 +63,7 @@ internal fun FileSaverRoute( private fun FileSaverScreen( onNavigateBack: () -> Unit, onDisplayFileDetails: (file: PlatformFile) -> Unit, + dialogSettingsTransform: (FileKitDialogSettings) -> FileKitDialogSettings = { it }, ) { var buttonState by remember { mutableStateOf(AppScreenHeaderButtonState.Enabled) } var suggestedName by remember { mutableStateOf("document") } @@ -67,8 +71,11 @@ private fun FileSaverScreen( var allowedExtensions by remember { mutableStateOf("pdf, txt") } var saveDirectory by remember { mutableStateOf(null) } var savedFiles by remember { mutableStateOf(emptyList()) } + val dialogSettings = dialogSettingsTransform(FileKitDialogSettings.createDefault()) - val fileSaverLauncher = rememberFileSaverLauncher { file -> + val fileSaverLauncher = rememberFileSaverLauncher( + dialogSettings = dialogSettings, + ) { file -> buttonState = AppScreenHeaderButtonState.Enabled if (file != null) { savedFiles = listOf(file) + savedFiles @@ -76,6 +83,7 @@ private fun FileSaverScreen( } val directoryPickerLauncher = rememberDirectoryPickerLauncher( directory = saveDirectory, + dialogSettings = dialogSettings, ) { directory -> if (directory != null) { saveDirectory = directory diff --git a/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.jvm.kt b/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.jvm.kt index 9202a849..5e2eb3ab 100644 --- a/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.jvm.kt +++ b/sample/shared/src/jvmMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.jvm.kt @@ -8,10 +8,11 @@ import io.github.vinceglb.filekit.dialogs.compose.rememberFileSaverLauncher as r @Composable internal actual fun rememberFileSaverLauncher( + dialogSettings: FileKitDialogSettings, onResult: (PlatformFile?) -> Unit, ): FileSaverLauncher { val launcher = rememberFileKitSaverLauncher( - dialogSettings = FileKitDialogSettings.createDefault(), + dialogSettings = dialogSettings, onResult = onResult, ) diff --git a/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.macos.kt b/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.macos.kt index 9202a849..5e2eb3ab 100644 --- a/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.macos.kt +++ b/sample/shared/src/macosMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.macos.kt @@ -8,10 +8,11 @@ import io.github.vinceglb.filekit.dialogs.compose.rememberFileSaverLauncher as r @Composable internal actual fun rememberFileSaverLauncher( + dialogSettings: FileKitDialogSettings, onResult: (PlatformFile?) -> Unit, ): FileSaverLauncher { val launcher = rememberFileKitSaverLauncher( - dialogSettings = FileKitDialogSettings.createDefault(), + dialogSettings = dialogSettings, onResult = onResult, ) diff --git a/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.mobile.kt b/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.mobile.kt index 9202a849..5e2eb3ab 100644 --- a/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.mobile.kt +++ b/sample/shared/src/mobileMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.mobile.kt @@ -8,10 +8,11 @@ import io.github.vinceglb.filekit.dialogs.compose.rememberFileSaverLauncher as r @Composable internal actual fun rememberFileSaverLauncher( + dialogSettings: FileKitDialogSettings, onResult: (PlatformFile?) -> Unit, ): FileSaverLauncher { val launcher = rememberFileKitSaverLauncher( - dialogSettings = FileKitDialogSettings.createDefault(), + dialogSettings = dialogSettings, onResult = onResult, ) diff --git a/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.web.kt b/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.web.kt index 574aa6a4..b97f82f0 100644 --- a/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.web.kt +++ b/sample/shared/src/webMain/kotlin/io/github/vinceglb/filekit/sample/shared/ui/screens/filesaver/FileSaverLauncher.web.kt @@ -3,9 +3,11 @@ package io.github.vinceglb.filekit.sample.shared.ui.screens.filesaver import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings @Composable internal actual fun rememberFileSaverLauncher( + dialogSettings: FileKitDialogSettings, onResult: (PlatformFile?) -> Unit, ): FileSaverLauncher = remember { object : FileSaverLauncher {