diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 00000000..d1fecef6
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,37 @@
+# File Access
+
+FileKit provides cross-platform references to user-selected files while respecting each platform's access model.
+
+## Language
+
+**Bookmark Data**:
+An opaque, platform-specific persistent reference to a file or directory. It may also preserve an access grant when the platform supports that capability, and must not be assumed to be portable between platforms.
+_Avoid_: Saved path, serialized file
+
+**Security-Scoped Bookmark**:
+A macOS bookmark that preserves sandbox access to a user-selected file or directory across application launches.
+_Avoid_: Apple bookmark, iOS security-scoped bookmark
+
+**Stale Bookmark**:
+A bookmark that the platform reports as still resolvable but in need of replacement with newly created bookmark data. Staleness is not a FileKit version marker and is not the same as an invalid bookmark.
+_Avoid_: Invalid bookmark, missing file
+
+**Legacy Bookmark Data**:
+Bookmark data created by an older FileKit behavior that does not preserve the access guarantees of the current platform implementation. FileKit attempts to resolve it permissively and only requires user reselection after an actual access or refresh failure.
+_Avoid_: Stale bookmark
+
+**Bookmark Refresh**:
+Advisory replacement of successfully resolved bookmark data because the platform reports it as stale or FileKit recognizes it as legacy. Refresh preserves or upgrades a reference; it does not mean the old bookmark is unusable and does not recover access that the operating system has already revoked.
+_Avoid_: Permission recovery, bookmark resolution
+
+**Bookmark Resolution**:
+Interpretation of stored bookmark data to recover a `PlatformFile` and information about the stored representation. Successful resolution identifies the resource but does not guarantee that every later file operation will succeed.
+_Avoid_: File access, permission grant
+
+**Access Capability**:
+Platform-granted authority carried by a `PlatformFile` to access a user-selected resource. A directory's capability also covers files derived within that directory.
+_Avoid_: Path permission, permanent access
+
+**Scoped Access**:
+A bounded period during which an application activates the access represented by a security-scoped resource.
+_Avoid_: Bookmark lifetime, permanent access
diff --git a/docs/adr/0001-versioned-macos-bookmarks.md b/docs/adr/0001-versioned-macos-bookmarks.md
new file mode 100644
index 00000000..36374247
--- /dev/null
+++ b/docs/adr/0001-versioned-macos-bookmarks.md
@@ -0,0 +1,20 @@
+---
+status: accepted
+---
+
+# Use Versioned Native Bookmarks for macOS Persistence
+
+FileKit will store new macOS bookmark data in a versioned FileKit envelope containing a native macOS bookmark. Bookmark creation will automatically use a security scope when the running application adopts App Sandbox and a regular native bookmark otherwise. Unwrapped data remains supported through platform-specific legacy resolution, and successfully resolved legacy data will be recommended for refresh.
+
+`PlatformFile` remains the capability-carrying abstraction: a file restored from a security-scoped bookmark retains that capability, descendants within a bookmarked directory inherit it, and FileKit balances scoped access around actual resource use. This preserves the existing API instead of introducing a separate access-session type.
+
+## Considered Options
+
+- Keeping JVM paths and unscoped native bookmarks was rejected because it cannot persist access for sandboxed macOS applications.
+- Always requesting a security-scoped bookmark was rejected because security scope is specific to App Sandbox and can fail for resources that remain valid regular bookmarks.
+- Rejecting or eagerly migrating all legacy data was rejected because old references may still be usable and FileKit cannot recreate access that macOS has already revoked.
+- Introducing an explicit scoped-file type was rejected because it would disrupt existing `PlatformFile` usage and weaken backward compatibility.
+
+## Consequences
+
+The bookmark envelope becomes a persistent compatibility contract and must be decoded defensively. Existing JVM-facing `PlatformFile` constructor, copy/component methods, equality, and binary signatures must remain compatible even if the implementation stops being a Kotlin data class internally. A fix is only complete after signed, sandboxed, cross-launch verification on both JVM and Kotlin/Native macOS.
diff --git a/docs/core/bookmark-data.mdx b/docs/core/bookmark-data.mdx
index b42fb5ae..33c8234d 100644
--- a/docs/core/bookmark-data.mdx
+++ b/docs/core/bookmark-data.mdx
@@ -37,6 +37,24 @@ if (savedBytes != null) {
}
```
+`fromBookmarkData()` remains the simplest restoration API. When your application can update its stored bookmark, use `resolveBookmarkData()` to inspect the persisted representation:
+
+```kotlin
+val resolution = PlatformFile.resolveBookmarkData(savedBytes)
+val restoredFile = resolution.file
+
+if (resolution.shouldRefresh) {
+ // Replace the bytes in your own storage.
+ val refreshedBookmark = restoredFile.bookmarkData()
+ MyPreferences.save("last_file_bookmark", refreshedBookmark.bytes)
+}
+```
+
+- `isStale` means the operating system resolved the bookmark but reported that its native representation should be recreated.
+- `shouldRefresh` is FileKit's broader recommendation. It is also `true` when FileKit successfully resolves bookmark data written by an older FileKit version.
+
+Refreshing is advisory. The old bookmark may still work, and refresh can fail if the resource disappeared or macOS already revoked access. In that case, ask the user to select the file or directory again.
+
## Complete Example
Here’s a more complete, practical example using a simple object to manage the bookmark.
@@ -131,13 +149,44 @@ FileKit abstracts away the details, but here's what happens on each platform:
- **Android**: For standard file paths, the path itself is stored. For `content://` URIs from the system picker, FileKit requests persistent URI permissions and stores the URI string. This ensures long-term access.
-- **iOS & macOS**: Uses the native security-scoped bookmark system. This is crucial for sandboxed apps. FileKit automatically starts and stops access to the security-scoped resource when you use the restored `PlatformFile`.
+- **iOS**: Uses Foundation bookmark data and keeps the security-scoped URL behavior supplied by the system document picker. The explicit persistent bookmark flags described below are macOS-only.
+
+- **Kotlin/Native macOS**: Uses a native macOS bookmark. FileKit automatically creates an explicit security-scoped bookmark when the running application adopts App Sandbox and a regular native bookmark otherwise.
+
+- **JVM macOS**: Uses the same versioned native bookmark format through CoreFoundation. A `PlatformFile` restored from a security-scoped bookmark retains its access capability, including for children inside a bookmarked directory.
+
+- **JVM Linux and Windows**: Stores the file path. These platforms keep their existing bookmark representation.
+
+New macOS bookmark data is wrapped in a versioned FileKit format. Existing unwrapped Kotlin/Native bookmarks and JVM path bytes remain readable. Bookmark bytes are platform-specific and must not be treated as portable between operating systems or applications.
+
+## macOS App Sandbox Setup
+
+Persistent access to user-selected locations outside a macOS application's container requires appropriate signing entitlements. Configure the packaged application with:
+
+```xml
+com.apple.security.app-sandbox
+
+com.apple.security.files.user-selected.read-write
+
+com.apple.security.files.bookmarks.app-scope
+
+```
+
+FileKit reads the signed App Sandbox entitlement at runtime and selects security-scoped bookmark creation automatically. When sandboxing is enabled, failure to create scoped bookmark data is reported instead of silently falling back to a bookmark that cannot restore access across launches.
+
+Keep security-scoped access balanced. FileKit does this around its own file operations and keeps access active until returned sources and sinks are closed. Use `withScopedAccess` when passing a restored file to another library:
+
+```kotlin
+restoredFile.withScopedAccess { file ->
+ thirdPartyLibrary.open(file.path)
+}
+```
-- **JVM**: Stores the file's absolute path. This is usually sufficient for desktop apps which are not typically sandboxed.
+Call `releaseBookmark()` when the restored bookmark is no longer needed. It prevents new scoped operations while allowing already-open sources and sinks to close cleanly.
-## Handling Invalid Bookmarks
+## Handling Invalid and Legacy Bookmarks
-A bookmark is not a guarantee. It can become invalid if the original file is deleted, moved, or if permissions change.
+A bookmark is not a guarantee. It can become invalid if the original file is deleted, moved, or if permissions change. A stale bookmark is different: it resolved successfully but should be recreated. Legacy bookmark data is also different: FileKit resolved an older representation and recommends replacing it.
**Always handle restoration failures gracefully.** A bookmark can become invalid if:
@@ -171,4 +220,4 @@ suspend fun loadFileSafely(): PlatformFile? {
}
}
```
-This defensive approach ensures your app doesn't crash from a stale bookmark and can self-heal by clearing invalid data.
+This defensive approach ensures your app doesn't crash from an invalid bookmark and can self-heal by clearing invalid data.
diff --git a/docs/plans/issue-590-macos-security-scoped-bookmarks.md b/docs/plans/issue-590-macos-security-scoped-bookmarks.md
new file mode 100644
index 00000000..97d8c3a2
--- /dev/null
+++ b/docs/plans/issue-590-macos-security-scoped-bookmarks.md
@@ -0,0 +1,139 @@
+# Issue 590: macOS Security-Scoped Bookmarks
+
+## Objective
+
+Make bookmark persistence work across application launches for sandboxed macOS applications on JVM and Kotlin/Native while preserving existing bookmark bytes and the published `PlatformFile` API.
+
+## Public API
+
+Add a successful resolution value:
+
+```kotlin
+public class BookmarkResolution(
+ public val file: PlatformFile,
+ public val isStale: Boolean,
+ public val shouldRefresh: Boolean,
+)
+```
+
+Add `PlatformFile.resolveBookmarkData(BookmarkData)` and its `ByteArray` convenience overload. Keep both existing `fromBookmarkData` overloads and delegate them to the new resolver while discarding the additional metadata.
+
+Keep resolution exception-based. Add `BookmarkResolutionException : FileKitException` with a reason that distinguishes invalid data, unsupported envelope versions, incompatible platforms, and unavailable resources. Existing callers that catch `FileKitException` remain compatible.
+
+`isStale` reflects the platform resolver's stale flag. `shouldRefresh` is advisory and is true for a stale bookmark or any successfully resolved legacy macOS representation. Refresh can fail and does not recover permission already revoked by macOS.
+
+## Bookmark Format
+
+Define a compact binary envelope with:
+
+- A FileKit bookmark magic value.
+- A format version.
+- A payload kind identifying a regular or security-scoped macOS native bookmark.
+- The opaque native bookmark payload.
+
+Reject recognized envelopes with unknown versions or incompatible payload kinds explicitly. When the magic value is absent, use the existing runtime-specific legacy decoder:
+
+- JVM macOS interprets the legacy bytes as the previously stored path.
+- Kotlin/Native macOS resolves the previous unwrapped Foundation bookmark.
+- Other platforms retain their current bookmark formats and behavior.
+
+Do not promise that bookmark data is portable between operating systems or applications.
+
+## macOS Bookmark Creation and Resolution
+
+Introduce a small platform abstraction shared by the macOS implementations:
+
+1. Detect the running process's `com.apple.security.app-sandbox` entitlement.
+2. In a sandboxed application, create and resolve bookmarks with the macOS security-scope options. Do not silently fall back to an unscoped bookmark if scoped creation fails.
+3. In an unsandboxed application, create and resolve regular native bookmarks.
+4. Capture the native stale flag during resolution.
+5. Preserve the resolved native URL as the access capability owned by the resulting `PlatformFile`.
+
+For Kotlin/Native, move the macOS-specific option selection out of the shared Apple behavior so iOS keeps its supported bookmark semantics. The macOS implementation uses Foundation APIs directly.
+
+For JVM macOS, add a focused JNA bridge to the stable CoreFoundation and Security C APIs. FileKit Core already depends on JNA Platform, so this must not add a bundled native library or JNI component. Guard all native loading behind a macOS runtime check.
+
+## Capability Lifetime
+
+Represent a restored macOS capability as a shared internal object containing its scope root and retained native URL. Derived files inherit it only when their normalized location is the root or a descendant of it.
+
+Balance every successful start with a stop. Support nested access safely and release retained native objects when no longer reachable.
+
+Audit all filesystem entry points. In particular:
+
+- Apply scoped access to currently uncovered operations such as existence checks and directory creation.
+- Keep access active until returned `RawSource` and `RawSink` instances are closed rather than stopping immediately after constructing them.
+- Preserve the capability through child resolution, applicable parent/absolute transformations, and JVM `copy()`.
+- Do not propagate a directory capability to a location outside its scope.
+
+`withScopedAccess` remains the explicit bridge for third-party libraries that operate on paths or handles outside FileKit's own operations.
+
+## JVM Compatibility
+
+If the JVM actual class can no longer remain a Kotlin data class, reproduce its published surface manually:
+
+- `PlatformFile(File)` constructor.
+- `component1()`.
+- `copy(File)` and its default-argument JVM helper.
+- Existing equality, hash-code, and string behavior.
+
+Capture the current JVM signatures before changing the class and compare the built artifact against that baseline. Add regression coverage for copying the same file, copying within a bookmarked directory, and copying outside the capability root.
+
+## Legacy Behavior
+
+Legacy resolution is permissive:
+
+1. Attempt the old decoder when no envelope is present.
+2. Return the file with `shouldRefresh = true` when resolution succeeds.
+3. Allow normal file access to proceed.
+4. Let the application regenerate and persist bookmark data.
+5. Require user reselection only after an actual access or refresh failure.
+
+Old sandboxed bookmarks may identify an external resource without carrying a persistent access grant. FileKit cannot upgrade those bytes after macOS has revoked access; this limitation must be documented explicitly.
+
+## Documentation
+
+Update the bookmark guide to:
+
+- Separate iOS, Kotlin/Native macOS, JVM macOS, and other JVM behavior.
+- Define `isStale`, `shouldRefresh`, legacy data, and refresh failure.
+- Show the new resolver and an opportunistic persistence refresh.
+- Explain that `fromBookmarkData` remains the simple compatibility API but does not expose refresh metadata.
+- Document App Sandbox, user-selected read/write access, and app-scoped bookmark entitlements for persistent external access.
+- Explain balanced scoped access and the `withScopedAccess` escape hatch.
+- Include the unavoidable reselection path for legacy bookmarks that never contained a persistent grant.
+
+## Verification
+
+Add automated coverage for:
+
+- Envelope round trips and malformed input.
+- Unknown versions and incompatible payload kinds.
+- Both JVM-path and Kotlin/Native Foundation legacy formats.
+- Current, stale, and legacy resolution metadata.
+- Automatic sandbox-mode selection through pure selectors fed by the platform entitlement readers.
+- Balanced and nested capability access.
+- Capability propagation within a directory and rejection outside it.
+- `RawSource` and `RawSink` lifetime behavior.
+- JVM public and binary compatibility.
+
+Run the repository gates required by `AGENTS.md`, including `./gradlew :filekit-core:check` and `./gradlew assemble`.
+
+Before closing the issue, manually verify signed sandboxed applications on JVM macOS and Kotlin/Native macOS:
+
+1. Pick an external directory.
+2. Persist its bookmark and write a file.
+3. Terminate the process completely.
+4. Relaunch and resolve the bookmark.
+5. Write again without presenting a picker.
+6. Confirm access is relinquished after use.
+7. Repeat with accessible and inaccessible legacy bookmark data.
+
+Record the operating system version, entitlements, packaging method, and results in the pull request description.
+
+## Out of Scope
+
+- Configurable read-only versus read/write bookmark modes.
+- Changing Android, iOS, Windows, or Linux bookmark formats.
+- Guaranteeing cross-platform or cross-application bookmark portability.
+- Silently replacing bookmark bytes in application-owned storage.
diff --git a/docs/plans/issue-590-platform-file-jvm-abi.txt b/docs/plans/issue-590-platform-file-jvm-abi.txt
new file mode 100644
index 00000000..59f30d8d
--- /dev/null
+++ b/docs/plans/issue-590-platform-file-jvm-abi.txt
@@ -0,0 +1,66 @@
+Issue #590 JVM PlatformFile ABI baseline
+========================================
+
+Source commit: 8fd0730f
+Parent production commit: 941c90b5
+Build command: ./gradlew :filekit-core:jvmJar --no-daemon
+Artifact: filekit-core/build/libs/filekit-core-jvm.jar
+Artifact SHA-256: 22c502c0dafc22bcd03ea52dc2e6fe3bc792b23c54c37d05d8a8bc79b0b115ca
+PlatformFile.class SHA-256: 57e82d716a93e27d946eac8357d7157d1363a3e2ecf001cb95c8a956a9596f45
+
+Captured with:
+
+ javap -classpath filekit-core/build/libs/filekit-core-jvm.jar -p -s \
+ io.github.vinceglb.filekit.PlatformFile \
+ 'io.github.vinceglb.filekit.PlatformFile$Companion' \
+ io.github.vinceglb.filekit.PlatformFile_jvmKt
+
+Compiled from "PlatformFile.jvm.kt"
+public final class io.github.vinceglb.filekit.PlatformFile {
+ public static final io.github.vinceglb.filekit.PlatformFile$Companion Companion;
+ descriptor: Lio/github/vinceglb/filekit/PlatformFile$Companion;
+ private final java.io.File file;
+ descriptor: Ljava/io/File;
+ public io.github.vinceglb.filekit.PlatformFile(java.io.File);
+ descriptor: (Ljava/io/File;)V
+ public final java.io.File getFile();
+ descriptor: ()Ljava/io/File;
+ public java.lang.String toString();
+ descriptor: ()Ljava/lang/String;
+ public final java.io.File component1();
+ descriptor: ()Ljava/io/File;
+ public final io.github.vinceglb.filekit.PlatformFile copy(java.io.File);
+ descriptor: (Ljava/io/File;)Lio/github/vinceglb/filekit/PlatformFile;
+ public static io.github.vinceglb.filekit.PlatformFile copy$default(io.github.vinceglb.filekit.PlatformFile, java.io.File, int, java.lang.Object);
+ descriptor: (Lio/github/vinceglb/filekit/PlatformFile;Ljava/io/File;ILjava/lang/Object;)Lio/github/vinceglb/filekit/PlatformFile;
+ public int hashCode();
+ descriptor: ()I
+ public boolean equals(java.lang.Object);
+ descriptor: (Ljava/lang/Object;)Z
+ static {};
+ descriptor: ()V
+}
+
+Compiled from "PlatformFile.jvm.kt"
+public final class io.github.vinceglb.filekit.PlatformFile$Companion {
+ private io.github.vinceglb.filekit.PlatformFile$Companion();
+ descriptor: ()V
+ public final kotlinx.serialization.KSerializer serializer();
+ descriptor: ()Lkotlinx/serialization/KSerializer;
+ public io.github.vinceglb.filekit.PlatformFile$Companion(kotlin.jvm.internal.DefaultConstructorMarker);
+ descriptor: (Lkotlin/jvm/internal/DefaultConstructorMarker;)V
+}
+
+Compiled from "PlatformFile.jvm.kt"
+public final class io.github.vinceglb.filekit.PlatformFile_jvmKt {
+ public static final boolean startAccessingSecurityScopedResource(io.github.vinceglb.filekit.PlatformFile);
+ descriptor: (Lio/github/vinceglb/filekit/PlatformFile;)Z
+ public static final void stopAccessingSecurityScopedResource(io.github.vinceglb.filekit.PlatformFile);
+ descriptor: (Lio/github/vinceglb/filekit/PlatformFile;)V
+ public static final java.lang.Object bookmarkData(io.github.vinceglb.filekit.PlatformFile, kotlin.coroutines.Continuation super io.github.vinceglb.filekit.BookmarkData>);
+ descriptor: (Lio/github/vinceglb/filekit/PlatformFile;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+ public static final void releaseBookmark(io.github.vinceglb.filekit.PlatformFile);
+ descriptor: (Lio/github/vinceglb/filekit/PlatformFile;)V
+ public static final io.github.vinceglb.filekit.PlatformFile fromBookmarkData(io.github.vinceglb.filekit.PlatformFile$Companion, io.github.vinceglb.filekit.BookmarkData);
+ descriptor: (Lio/github/vinceglb/filekit/PlatformFile$Companion;Lio/github/vinceglb/filekit/BookmarkData;)Lio/github/vinceglb/filekit/PlatformFile;
+}
diff --git a/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt b/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt
index bfb876e7..4b865392 100644
--- a/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt
+++ b/filekit-core/src/androidMain/kotlin/io/github/vinceglb/filekit/PlatformFile.android.kt
@@ -533,7 +533,11 @@ private fun Uri.getUriToRelease(isDirectory: Boolean): Uri = if (isDirectory) {
public actual fun PlatformFile.Companion.fromBookmarkData(
bookmarkData: BookmarkData,
-): PlatformFile {
+): PlatformFile = resolveBookmarkData(bookmarkData).file
+
+public actual fun PlatformFile.Companion.resolveBookmarkData(
+ bookmarkData: BookmarkData,
+): BookmarkResolution {
val str = bookmarkData.bytes.decodeToString()
val platformFile = when {
@@ -575,7 +579,11 @@ public actual fun PlatformFile.Companion.fromBookmarkData(
throw FileKitException("Bookmark target is no longer accessible")
}
- return platformFile
+ return BookmarkResolution(
+ file = platformFile,
+ isStale = false,
+ shouldRefresh = false,
+ )
}
private fun getUriFileSize(uri: Uri): Long? = UriMetadataResolver.size(uri)
diff --git a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt
new file mode 100644
index 00000000..a97ea15c
--- /dev/null
+++ b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.apple.kt
@@ -0,0 +1,27 @@
+package io.github.vinceglb.filekit
+
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure
+import platform.Foundation.NSError
+
+internal data class AppleBookmarkCreationConfiguration(
+ val options: ULong,
+ val kind: MacOsBookmarkKind?,
+)
+
+internal data class AppleBookmarkPayload(
+ val bytes: ByteArray,
+ val resolutionOptions: ULong,
+ val isLegacy: Boolean,
+ val kind: MacOsBookmarkKind? = null,
+)
+
+internal expect fun appleBookmarkCreationConfiguration(): AppleBookmarkCreationConfiguration
+
+internal expect fun encodeAppleBookmarkPayload(
+ payload: ByteArray,
+ configuration: AppleBookmarkCreationConfiguration,
+): ByteArray
+
+internal expect fun decodeAppleBookmarkPayload(bytes: ByteArray): AppleBookmarkPayload
+
+internal expect fun classifyAppleBookmarkResolutionError(error: NSError?): BookmarkResolutionFailure
diff --git a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt
index fd2090dd..cc3e6505 100644
--- a/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt
+++ b/filekit-core/src/appleMain/kotlin/io/github/vinceglb/filekit/PlatformFile.apple.kt
@@ -1,11 +1,13 @@
package io.github.vinceglb.filekit
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException
import io.github.vinceglb.filekit.exceptions.FileKitException
import io.github.vinceglb.filekit.mimeType.MimeType
import io.github.vinceglb.filekit.utils.toByteArray
import io.github.vinceglb.filekit.utils.toKotlinxPath
import io.github.vinceglb.filekit.utils.toNSData
import kotlinx.cinterop.BetaInteropApi
+import kotlinx.cinterop.BooleanVar
import kotlinx.cinterop.ByteVar
import kotlinx.cinterop.CPointer
import kotlinx.cinterop.ExperimentalForeignApi
@@ -13,6 +15,7 @@ import kotlinx.cinterop.ObjCObjectVar
import kotlinx.cinterop.UnsafeNumber
import kotlinx.cinterop.alloc
import kotlinx.cinterop.allocArray
+import kotlinx.cinterop.convert
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.pointed
import kotlinx.cinterop.ptr
@@ -54,20 +57,57 @@ import kotlin.time.Instant
*/
@Serializable(with = PlatformFileSerializer::class)
public actual data class PlatformFile(
- val nsUrl: NSURL,
+ public val nsUrl: NSURL,
) {
+ internal var macOsBookmarkLease: MacOsBookmarkLease? = null
+
public actual override fun toString(): String = path
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (other !is PlatformFile) return false
- if (nsUrl.path != other.nsUrl.path) return false
- return true
+ public actual companion object {
+ internal fun withMacOsBookmarkLease(url: NSURL): PlatformFile = PlatformFile(url).also {
+ it.macOsBookmarkLease = MacOsBookmarkLease(url)
+ }
+ }
+}
+
+@OptIn(ExperimentalForeignApi::class)
+internal class MacOsBookmarkLease(
+ url: NSURL,
+) {
+ private val rootPath = url.standardizedPath
+ private var scopeUrl: NSURL? = url
+ private var activeAccesses = 0
+ private var released = false
+
+ fun covers(url: NSURL): Boolean = url.standardizedPath.isWithin(rootPath)
+
+ fun start(): Boolean {
+ check(!released) { "This security-scoped bookmark has been released" }
+ val granted = requireNotNull(scopeUrl).startAccessingSecurityScopedResource()
+ if (granted) {
+ activeAccesses += 1
+ }
+ return granted
+ }
+
+ fun stop() {
+ if (activeAccesses == 0) return
+ requireNotNull(scopeUrl).stopAccessingSecurityScopedResource()
+ activeAccesses -= 1
+ releaseNativeUrlIfDrained()
}
- override fun hashCode(): Int = nsUrl.path.hashCode()
+ fun release() {
+ if (released) return
+ released = true
+ releaseNativeUrlIfDrained()
+ }
- public actual companion object
+ private fun releaseNativeUrlIfDrained() {
+ if (released && activeAccesses == 0) {
+ scopeUrl = null
+ }
+ }
}
public actual fun PlatformFile(path: Path): PlatformFile =
@@ -80,6 +120,31 @@ public actual fun PlatformFile(path: Path): PlatformFile =
public actual fun PlatformFile.toKotlinxIoPath(): Path =
nsUrl.toKotlinxPath()
+@PublishedApi
+internal actual fun PlatformFile.withPath(path: Path): PlatformFile =
+ PlatformFile(path).copyMacOsBookmarkLeaseFrom(this)
+
+private fun PlatformFile.copyMacOsBookmarkLeaseFrom(source: PlatformFile): PlatformFile = apply {
+ macOsBookmarkLease = source.macOsBookmarkLease?.takeIf { it.covers(nsUrl) }
+}
+
+private fun String.isWithin(rootPath: String): Boolean {
+ val root = rootPath.trimEnd('/')
+ return when {
+ root.isEmpty() && rootPath.startsWith('/') -> startsWith('/')
+ this == root -> true
+ root.isNotEmpty() -> startsWith("$root/")
+ else -> false
+ }
+}
+
+private val NSURL.standardizedPath: String
+ get() = runCatching {
+ SystemFileSystem.resolve(toKotlinxPath()).toString()
+ }.getOrElse {
+ URLByStandardizingPath?.path.orEmpty()
+ }
+
public actual val PlatformFile.extension: String
get() = nsUrl.pathExtension ?: ""
@@ -93,7 +158,7 @@ public actual inline fun PlatformFile.list(block: (List) -> Unit):
withScopedAccess {
val directoryFiles = SystemFileSystem
.list(toKotlinxIoPath())
- .map { PlatformFile(NSURL.fileURLWithPath(it.toString())) }
+ .map(::withPath)
block(directoryFiles)
}
@@ -101,21 +166,21 @@ public actual fun PlatformFile.list(): List =
withScopedAccess {
SystemFileSystem
.list(toKotlinxIoPath())
- .map { PlatformFile(NSURL.fileURLWithPath(it.toString())) }
+ .map(::withPath)
}
@OptIn(ExperimentalForeignApi::class, ExperimentalTime::class)
-public actual fun PlatformFile.createdAt(): Instant? {
- val values = this.nsUrl.resourceValuesForKeys(listOf(NSURLCreationDateKey), null)
+public actual fun PlatformFile.createdAt(): Instant? = withScopedAccess {
+ val values = nsUrl.resourceValuesForKeys(listOf(NSURLCreationDateKey), null)
val date = values?.get(NSURLCreationDateKey) as? NSDate
- return Instant.fromEpochSeconds(date?.timeIntervalSince1970?.toLong() ?: 0L)
+ Instant.fromEpochSeconds(date?.timeIntervalSince1970?.toLong() ?: 0L)
}
@OptIn(ExperimentalForeignApi::class, ExperimentalTime::class)
-public actual fun PlatformFile.lastModified(): Instant {
- val values = this.nsUrl.resourceValuesForKeys(listOf(NSURLContentModificationDateKey), null)
+public actual fun PlatformFile.lastModified(): Instant = withScopedAccess {
+ val values = nsUrl.resourceValuesForKeys(listOf(NSURLContentModificationDateKey), null)
val date = values?.get(NSURLContentModificationDateKey) as? NSDate
- return Instant.fromEpochSeconds(date?.timeIntervalSince1970?.toLong() ?: 0L)
+ Instant.fromEpochSeconds(date?.timeIntervalSince1970?.toLong() ?: 0L)
}
public actual fun PlatformFile.mimeType(): MimeType? = withScopedAccess { file ->
@@ -209,43 +274,97 @@ private fun cfStringToKString(cfString: CFStringRef?): String? {
}
public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean =
- nsUrl.startAccessingSecurityScopedResource()
+ macOsBookmarkLease?.start() ?: nsUrl.startAccessingSecurityScopedResource()
public actual fun PlatformFile.stopAccessingSecurityScopedResource(): Unit =
- nsUrl.stopAccessingSecurityScopedResource()
+ macOsBookmarkLease?.stop() ?: nsUrl.stopAccessingSecurityScopedResource()
@OptIn(ExperimentalForeignApi::class, UnsafeNumber::class, BetaInteropApi::class)
public actual suspend fun PlatformFile.bookmarkData(): BookmarkData = withContext(Dispatchers.IO) {
withScopedAccess {
memScoped {
+ val configuration = appleBookmarkCreationConfiguration()
val errorPtr = alloc>()
val bookmarkData = nsUrl.bookmarkDataWithOptions(
- options = 0u,
+ options = configuration.options.convert(),
includingResourceValuesForKeys = null,
relativeToURL = null,
error = errorPtr.ptr,
) ?: throw FileKitException("Failed to create bookmark data: ${errorPtr.ptr.pointed.value}")
- BookmarkData(bookmarkData.toByteArray())
+ BookmarkData(
+ encodeAppleBookmarkPayload(
+ payload = bookmarkData.toByteArray(),
+ configuration = configuration,
+ ),
+ )
}
}
}
-public actual fun PlatformFile.releaseBookmark() {}
+public actual fun PlatformFile.releaseBookmark() {
+ macOsBookmarkLease?.release()
+}
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class, UnsafeNumber::class)
public actual fun PlatformFile.Companion.fromBookmarkData(
bookmarkData: BookmarkData,
-): PlatformFile = memScoped {
- val nsData = bookmarkData.bytes.toNSData()
- val error: CPointer> = alloc>().ptr
+): PlatformFile = resolveBookmarkData(bookmarkData).file
+
+@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class, UnsafeNumber::class)
+public actual fun PlatformFile.Companion.resolveBookmarkData(
+ bookmarkData: BookmarkData,
+): BookmarkResolution {
+ val payload = decodeAppleBookmarkPayload(bookmarkData.bytes)
+ val nativeResolution = resolveAppleBookmark(
+ payload.bytes,
+ payload.resolutionOptions,
+ )
+ return appleBookmarkResolution(payload, nativeResolution)
+}
+
+internal fun appleBookmarkResolution(
+ payload: AppleBookmarkPayload,
+ nativeResolution: AppleBookmarkNativeResolution,
+): BookmarkResolution {
+ val file = if (payload.kind == MacOsBookmarkKind.SecurityScoped) {
+ PlatformFile.withMacOsBookmarkLease(nativeResolution.url)
+ } else {
+ PlatformFile(nativeResolution.url)
+ }
+ return BookmarkResolution(
+ file = file,
+ isStale = nativeResolution.isStale,
+ shouldRefresh = nativeResolution.isStale || payload.isLegacy,
+ )
+}
+internal data class AppleBookmarkNativeResolution(
+ val url: NSURL,
+ val isStale: Boolean,
+)
+
+@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class, UnsafeNumber::class)
+private fun resolveAppleBookmark(
+ bytes: ByteArray,
+ resolutionOptions: ULong,
+): AppleBookmarkNativeResolution = memScoped {
+ val error: CPointer> = alloc>().ptr
+ val isStale = alloc()
val restoredUrl = NSURL.URLByResolvingBookmarkData(
- bookmarkData = nsData,
- options = 0u,
+ bookmarkData = bytes.toNSData(),
+ options = resolutionOptions.convert(),
relativeToURL = null,
- bookmarkDataIsStale = null,
+ bookmarkDataIsStale = isStale.ptr,
error = error,
- ) ?: throw FileKitException("Failed to resolve bookmark data: ${error.pointed.value}")
+ ) ?: throw error.pointed.value.toBookmarkResolutionException()
- PlatformFile(restoredUrl)
+ AppleBookmarkNativeResolution(
+ url = restoredUrl,
+ isStale = isStale.value,
+ )
}
+
+private fun NSError?.toBookmarkResolutionException(): BookmarkResolutionException = BookmarkResolutionException(
+ reason = classifyAppleBookmarkResolutionError(this),
+ message = "Failed to resolve bookmark data: $this",
+)
diff --git a/filekit-core/src/iosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.ios.kt b/filekit-core/src/iosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.ios.kt
new file mode 100644
index 00000000..00d71cd8
--- /dev/null
+++ b/filekit-core/src/iosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.ios.kt
@@ -0,0 +1,18 @@
+package io.github.vinceglb.filekit
+
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure
+import platform.Foundation.NSError
+
+internal actual fun appleBookmarkCreationConfiguration(): AppleBookmarkCreationConfiguration =
+ AppleBookmarkCreationConfiguration(options = 0u, kind = null)
+
+internal actual fun encodeAppleBookmarkPayload(
+ payload: ByteArray,
+ configuration: AppleBookmarkCreationConfiguration,
+): ByteArray = payload
+
+internal actual fun decodeAppleBookmarkPayload(bytes: ByteArray): AppleBookmarkPayload =
+ AppleBookmarkPayload(bytes = bytes, resolutionOptions = 0u, isLegacy = false)
+
+internal actual fun classifyAppleBookmarkResolutionError(error: NSError?): BookmarkResolutionFailure =
+ BookmarkResolutionFailure.RESOURCE_UNAVAILABLE
diff --git a/filekit-core/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvmAndNative.kt b/filekit-core/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvmAndNative.kt
index 9b17183f..0da7dd8f 100644
--- a/filekit-core/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvmAndNative.kt
+++ b/filekit-core/src/jvmAndNativeMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvmAndNative.kt
@@ -4,6 +4,7 @@ import io.github.vinceglb.filekit.utils.div
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
+import kotlinx.io.Buffer
import kotlinx.io.RawSink
import kotlinx.io.RawSource
import kotlinx.io.files.Path
@@ -19,7 +20,7 @@ public actual fun PlatformFile(path: String): PlatformFile =
PlatformFile(Path(path))
public actual fun PlatformFile(base: PlatformFile, child: String): PlatformFile =
- PlatformFile(base.toKotlinxIoPath() / child)
+ base.withPath(base.toKotlinxIoPath() / child)
public actual fun PlatformFile.isRegularFile(): Boolean = withScopedAccess {
SystemFileSystem.metadataOrNull(toKotlinxIoPath())?.isRegularFile ?: false
@@ -32,30 +33,51 @@ public actual fun PlatformFile.isDirectory(): Boolean = withScopedAccess {
public actual fun PlatformFile.isAbsolute(): Boolean =
toKotlinxIoPath().isAbsolute
-public actual fun PlatformFile.exists(): Boolean =
+public actual fun PlatformFile.exists(): Boolean = withScopedAccess {
SystemFileSystem.exists(toKotlinxIoPath())
+}
public actual fun PlatformFile.size(): Long = withScopedAccess {
SystemFileSystem.metadataOrNull(toKotlinxIoPath())?.size ?: -1
}
public actual fun PlatformFile.parent(): PlatformFile? =
- toKotlinxIoPath().parent?.let(::PlatformFile)
+ toKotlinxIoPath().parent?.let(::withPath)
public actual fun PlatformFile.absoluteFile(): PlatformFile = withScopedAccess {
- PlatformFile(SystemFileSystem.resolve(toKotlinxIoPath()))
+ withPath(SystemFileSystem.resolve(toKotlinxIoPath()))
}
-public actual fun PlatformFile.source(): RawSource = withScopedAccess {
- SystemFileSystem.source(toKotlinxIoPath())
+public actual fun PlatformFile.source(): RawSource {
+ val accessGranted = startAccessingSecurityScopedResource()
+ try {
+ return ScopedRawSource(
+ delegate = SystemFileSystem.source(toKotlinxIoPath()),
+ onClose = { if (accessGranted) stopAccessingSecurityScopedResource() },
+ )
+ } catch (error: Throwable) {
+ if (accessGranted) stopAccessingSecurityScopedResource()
+ throw error
+ }
}
-public actual fun PlatformFile.sink(append: Boolean): RawSink = withScopedAccess {
- SystemFileSystem.sink(toKotlinxIoPath(), append)
+public actual fun PlatformFile.sink(append: Boolean): RawSink {
+ val accessGranted = startAccessingSecurityScopedResource()
+ try {
+ return ScopedRawSink(
+ delegate = SystemFileSystem.sink(toKotlinxIoPath(), append),
+ onClose = { if (accessGranted) stopAccessingSecurityScopedResource() },
+ )
+ } catch (error: Throwable) {
+ if (accessGranted) stopAccessingSecurityScopedResource()
+ throw error
+ }
}
public actual fun PlatformFile.createDirectories(mustCreate: Boolean): Unit =
- SystemFileSystem.createDirectories(toKotlinxIoPath(), mustCreate)
+ withScopedAccess {
+ SystemFileSystem.createDirectories(toKotlinxIoPath(), mustCreate)
+ }
public actual suspend fun PlatformFile.delete(mustExist: Boolean): Unit =
withScopedAccess {
@@ -81,7 +103,7 @@ public actual suspend fun PlatformFile.atomicMove(destination: PlatformFile): Un
private fun PlatformFile.resolveAtomicMoveDestination(source: PlatformFile): PlatformFile {
if (isDirectory()) {
- return PlatformFile(toKotlinxIoPath() / source.name)
+ return withPath(toKotlinxIoPath() / source.name)
}
return this
}
@@ -90,8 +112,55 @@ internal actual suspend fun PlatformFile.prepareDestinationForWrite(
source: PlatformFile,
): PlatformFile = withScopedAccess {
if (isDirectory()) {
- PlatformFile(toKotlinxIoPath() / source.name)
+ withPath(toKotlinxIoPath() / source.name)
} else {
this
}
}
+
+@PublishedApi
+internal expect fun PlatformFile.withPath(path: Path): PlatformFile
+
+private class ScopedRawSource(
+ private val delegate: RawSource,
+ private val onClose: () -> Unit,
+) : RawSource {
+ private var closed = false
+
+ override fun readAtMostTo(sink: Buffer, byteCount: Long): Long = delegate.readAtMostTo(sink, byteCount)
+
+ override fun close() {
+ if (closed) return
+ closed = true
+ try {
+ delegate.close()
+ } finally {
+ onClose()
+ }
+ }
+}
+
+private class ScopedRawSink(
+ private val delegate: RawSink,
+ private val onClose: () -> Unit,
+) : RawSink {
+ private var closed = false
+
+ override fun write(source: Buffer, byteCount: Long) {
+ delegate.write(source, byteCount)
+ }
+
+ override fun flush() {
+ delegate.flush()
+ }
+
+ override fun close() {
+ if (closed) return
+ closed = true
+ try {
+ delegate.close()
+ } finally {
+ onClose()
+ }
+ }
+}
diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt
new file mode 100644
index 00000000..ab25fceb
--- /dev/null
+++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/MacOsBookmark.jvm.kt
@@ -0,0 +1,356 @@
+package io.github.vinceglb.filekit
+
+import com.sun.jna.Library
+import com.sun.jna.Memory
+import com.sun.jna.Native
+import com.sun.jna.NativeLong
+import com.sun.jna.Platform
+import com.sun.jna.Pointer
+import com.sun.jna.PointerType
+import com.sun.jna.platform.mac.CoreFoundation
+import com.sun.jna.ptr.ByteByReference
+import com.sun.jna.ptr.PointerByReference
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure
+import java.io.File
+import java.lang.ref.Cleaner
+
+internal data class ResolvedMacOsBookmark(
+ val file: File,
+ val isStale: Boolean,
+ val access: MacOsBookmarkAccess?,
+)
+
+internal interface MacOsBookmarkAccess {
+ fun covers(file: File): Boolean
+
+ fun start(): Boolean
+
+ fun stop()
+
+ fun release()
+}
+
+private class NativeMacOsBookmarkAccess(
+ root: File,
+ private val url: CFUrlRef,
+) : MacOsBookmarkAccess {
+ private val rootPath = root.canonicalFile.toPath()
+ private var activeAccesses = 0
+ private var released = false
+
+ @Suppress("unused")
+ private val cleanable = cleaner.register(this, NativeUrlReleaser(url))
+
+ override fun covers(file: File): Boolean = file.canonicalFile.toPath().startsWith(rootPath)
+
+ override fun start(): Boolean = synchronized(this) {
+ check(!released) { "This security-scoped bookmark has been released" }
+ val granted = CoreFoundationBookmarkApi.instance.CFURLStartAccessingSecurityScopedResource(url) != 0.toByte()
+ if (granted) {
+ activeAccesses += 1
+ }
+ granted
+ }
+
+ override fun stop() {
+ synchronized(this) {
+ if (activeAccesses == 0) return
+ CoreFoundationBookmarkApi.instance.CFURLStopAccessingSecurityScopedResource(url)
+ activeAccesses -= 1
+ releaseNativeUrlIfDrained()
+ }
+ }
+
+ override fun release() {
+ synchronized(this) {
+ if (released) return
+ released = true
+ releaseNativeUrlIfDrained()
+ }
+ }
+
+ private fun releaseNativeUrlIfDrained() {
+ if (released && activeAccesses == 0) {
+ cleanable.clean()
+ }
+ }
+
+ companion object {
+ private val cleaner = Cleaner.create()
+ }
+
+ private class NativeUrlReleaser(
+ private val url: CFUrlRef,
+ ) : Runnable {
+ override fun run() {
+ url.release()
+ }
+ }
+}
+
+private fun readAppSandboxEntitlement(): Boolean {
+ check(Platform.isMac())
+ val task = SecurityApi.instance.SecTaskCreateFromSelf(null)
+ ?: throw IllegalStateException("Could not inspect the App Sandbox entitlement")
+ try {
+ val entitlement = CoreFoundation.CFStringRef.createCFString(APP_SANDBOX_ENTITLEMENT)
+ ?: throw IllegalStateException("Could not create the App Sandbox entitlement name")
+ try {
+ val error = PointerByReference()
+ val value = SecurityApi.instance.SecTaskCopyValueForEntitlement(task, entitlement, error)
+ if (value == null) {
+ error.value?.let { throw CoreFoundation.CFTypeRef(it).toEntitlementException() }
+ return false
+ }
+ try {
+ if (!value.isTypeID(CoreFoundation.BOOLEAN_TYPE_ID)) {
+ throw IllegalStateException("The App Sandbox entitlement is not a Boolean")
+ }
+ return CoreFoundation.INSTANCE.CFBooleanGetValue(
+ CoreFoundation.CFBooleanRef(value.pointer),
+ ) != 0.toByte()
+ } finally {
+ value.release()
+ }
+ } finally {
+ entitlement.release()
+ }
+ } finally {
+ task.release()
+ }
+}
+
+private fun CoreFoundation.CFTypeRef.toEntitlementException(): IllegalStateException {
+ try {
+ val description = CoreFoundationBookmarkApi.instance.CFErrorCopyDescription(this)
+ val message = try {
+ description?.stringValue() ?: "Could not inspect the App Sandbox entitlement"
+ } finally {
+ description?.release()
+ }
+ return IllegalStateException(message)
+ } finally {
+ release()
+ }
+}
+
+internal fun macOsBookmarkKind(isSandboxed: Boolean): MacOsBookmarkKind = if (isSandboxed) {
+ MacOsBookmarkKind.SecurityScoped
+} else {
+ MacOsBookmarkKind.Regular
+}
+
+internal fun macOsBookmarkKindForCurrentProcess(): MacOsBookmarkKind =
+ macOsBookmarkKind(isSandboxed = readAppSandboxEntitlement())
+
+internal object MacOsBookmarks {
+ fun create(file: File, kind: MacOsBookmarkKind): ByteArray {
+ check(Platform.isMac())
+ val path = CoreFoundation.CFStringRef.createCFString(file.canonicalPath)
+ try {
+ val url = CoreFoundationBookmarkApi.instance.CFURLCreateWithFileSystemPath(
+ allocator = null,
+ filePath = path,
+ pathStyle = POSIX_PATH_STYLE,
+ isDirectory = if (file.isDirectory) 1 else 0,
+ ) ?: throw BookmarkResolutionException(
+ reason = BookmarkResolutionFailure.RESOURCE_UNAVAILABLE,
+ message = "Could not create a native URL for ${file.path}",
+ )
+ try {
+ val bookmark = CoreFoundationBookmarkApi.instance.CFURLCreateBookmarkData(
+ allocator = null,
+ url = url,
+ options = NativeLong(kind.creationOptions),
+ resourcePropertiesToInclude = null,
+ relativeToUrl = null,
+ error = null,
+ ) ?: throw BookmarkResolutionException(
+ reason = BookmarkResolutionFailure.RESOURCE_UNAVAILABLE,
+ message = "Could not create bookmark data for ${file.path}",
+ )
+ try {
+ return bookmark.bytePtr.getByteArray(0, bookmark.length)
+ } finally {
+ bookmark.release()
+ }
+ } finally {
+ url.release()
+ }
+ } finally {
+ path.release()
+ }
+ }
+
+ fun resolve(payload: ByteArray, kind: MacOsBookmarkKind): ResolvedMacOsBookmark {
+ check(Platform.isMac())
+ val payloadMemory = Memory(payload.size.toLong()).apply { write(0, payload, 0, payload.size) }
+ val bookmarkData = CoreFoundation.INSTANCE.CFDataCreate(
+ null,
+ payloadMemory,
+ CoreFoundation.CFIndex(payload.size.toLong()),
+ )
+ try {
+ val isStale = ByteByReference()
+ val error = PointerByReference()
+ val url = CoreFoundationBookmarkApi.instance.CFURLCreateByResolvingBookmarkData(
+ allocator = null,
+ bookmark = bookmarkData,
+ options = NativeLong(kind.resolutionOptions),
+ relativeToUrl = null,
+ resourcePropertiesToInclude = null,
+ isStale = isStale,
+ error = error,
+ ) ?: throw error.toBookmarkResolutionException()
+ var releaseUrl = true
+ try {
+ val path = CoreFoundationBookmarkApi.instance.CFURLCopyFileSystemPath(url, POSIX_PATH_STYLE)
+ ?: throw BookmarkResolutionException(
+ reason = BookmarkResolutionFailure.RESOURCE_UNAVAILABLE,
+ message = "The resolved macOS bookmark has no file-system path",
+ )
+ try {
+ val file = File(path.stringValue())
+ val access = if (kind == MacOsBookmarkKind.SecurityScoped) {
+ releaseUrl = false
+ NativeMacOsBookmarkAccess(file, url)
+ } else {
+ null
+ }
+ return ResolvedMacOsBookmark(
+ file = file,
+ isStale = isStale.value != 0.toByte(),
+ access = access,
+ )
+ } finally {
+ path.release()
+ }
+ } finally {
+ if (releaseUrl) url.release()
+ }
+ } finally {
+ bookmarkData.release()
+ }
+ }
+}
+
+private fun PointerByReference.toBookmarkResolutionException(): BookmarkResolutionException {
+ val errorPointer = value ?: return BookmarkResolutionException(
+ reason = BookmarkResolutionFailure.RESOURCE_UNAVAILABLE,
+ message = "Could not resolve macOS bookmark data",
+ )
+ val error = CoreFoundation.CFTypeRef(errorPointer)
+ try {
+ val domain = CoreFoundationBookmarkApi.instance.CFErrorGetDomain(error)?.stringValue()
+ val code = CoreFoundationBookmarkApi.instance.CFErrorGetCode(error).toLong()
+ val description = CoreFoundationBookmarkApi.instance.CFErrorCopyDescription(error)
+ val message = try {
+ description?.stringValue() ?: "Could not resolve macOS bookmark data"
+ } finally {
+ description?.release()
+ }
+ return BookmarkResolutionException(
+ reason = if (domain == COCOA_ERROR_DOMAIN && code == NS_FILE_READ_CORRUPT_ERROR_CODE) {
+ BookmarkResolutionFailure.INVALID_DATA
+ } else {
+ BookmarkResolutionFailure.RESOURCE_UNAVAILABLE
+ },
+ message = message,
+ )
+ } finally {
+ error.release()
+ }
+}
+
+private val MacOsBookmarkKind.creationOptions: Long
+ get() = when (this) {
+ MacOsBookmarkKind.Regular -> 0
+ MacOsBookmarkKind.SecurityScoped -> 1L shl 11
+ }
+
+private val MacOsBookmarkKind.resolutionOptions: Long
+ get() = when (this) {
+ MacOsBookmarkKind.Regular -> 0
+ MacOsBookmarkKind.SecurityScoped -> 1L shl 10
+ }
+
+private const val POSIX_PATH_STYLE = 0
+
+internal class CFUrlRef : PointerType {
+ constructor() : super()
+ constructor(pointer: Pointer) : super(pointer)
+
+ fun release() {
+ CoreFoundation.INSTANCE.CFRelease(CoreFoundation.CFTypeRef(pointer))
+ }
+}
+
+@Suppress("ktlint:standard:function-naming", "FunctionName")
+internal interface CoreFoundationBookmarkApi : Library {
+ fun CFURLCreateWithFileSystemPath(
+ allocator: CoreFoundation.CFAllocatorRef?,
+ filePath: CoreFoundation.CFStringRef,
+ pathStyle: Int,
+ isDirectory: Byte,
+ ): CFUrlRef?
+
+ fun CFURLCreateBookmarkData(
+ allocator: CoreFoundation.CFAllocatorRef?,
+ url: CFUrlRef,
+ options: NativeLong,
+ resourcePropertiesToInclude: CoreFoundation.CFArrayRef?,
+ relativeToUrl: CFUrlRef?,
+ error: PointerByReference?,
+ ): CoreFoundation.CFDataRef?
+
+ fun CFURLCreateByResolvingBookmarkData(
+ allocator: CoreFoundation.CFAllocatorRef?,
+ bookmark: CoreFoundation.CFDataRef,
+ options: NativeLong,
+ relativeToUrl: CFUrlRef?,
+ resourcePropertiesToInclude: CoreFoundation.CFArrayRef?,
+ isStale: ByteByReference,
+ error: PointerByReference?,
+ ): CFUrlRef?
+
+ fun CFURLCopyFileSystemPath(url: CFUrlRef, pathStyle: Int): CoreFoundation.CFStringRef?
+
+ fun CFURLStartAccessingSecurityScopedResource(url: CFUrlRef): Byte
+
+ fun CFURLStopAccessingSecurityScopedResource(url: CFUrlRef)
+
+ fun CFErrorGetCode(error: CoreFoundation.CFTypeRef): CoreFoundation.CFIndex
+
+ fun CFErrorGetDomain(error: CoreFoundation.CFTypeRef): CoreFoundation.CFStringRef?
+
+ fun CFErrorCopyDescription(error: CoreFoundation.CFTypeRef): CoreFoundation.CFStringRef?
+
+ companion object {
+ val instance: CoreFoundationBookmarkApi = Native.load("CoreFoundation", CoreFoundationBookmarkApi::class.java)
+ }
+}
+
+internal class SecTaskRef : PointerType {
+ constructor() : super()
+ constructor(pointer: Pointer) : super(pointer)
+
+ fun release() {
+ CoreFoundation.INSTANCE.CFRelease(CoreFoundation.CFTypeRef(pointer))
+ }
+}
+
+@Suppress("ktlint:standard:function-naming", "FunctionName")
+internal interface SecurityApi : Library {
+ fun SecTaskCreateFromSelf(allocator: CoreFoundation.CFAllocatorRef?): SecTaskRef?
+
+ fun SecTaskCopyValueForEntitlement(
+ task: SecTaskRef,
+ entitlement: CoreFoundation.CFStringRef,
+ error: PointerByReference?,
+ ): CoreFoundation.CFTypeRef?
+
+ companion object {
+ val instance: SecurityApi = Native.load("Security", SecurityApi::class.java)
+ }
+}
diff --git a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt
index 9bcaeefd..d2938987 100644
--- a/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt
+++ b/filekit-core/src/jvmMain/kotlin/io/github/vinceglb/filekit/PlatformFile.jvm.kt
@@ -1,5 +1,8 @@
package io.github.vinceglb.filekit
+import com.sun.jna.Platform
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure
import io.github.vinceglb.filekit.mimeType.MimeType
import io.github.vinceglb.filekit.utils.toFile
import io.github.vinceglb.filekit.utils.toKotlinxIoPath
@@ -20,12 +23,45 @@ import kotlin.time.Instant
* @property file The underlying [java.io.File] object.
*/
@Serializable(with = PlatformFileSerializer::class)
-public actual data class PlatformFile(
- val file: File,
+public actual class PlatformFile private constructor(
+ public val file: File,
+ private val macOsBookmarkAccess: MacOsBookmarkAccess?,
) {
+ public constructor(file: File) : this(file, null)
+
public actual override fun toString(): String = path
- public actual companion object
+ public operator fun component1(): File = file
+
+ public fun copy(file: File = this.file): PlatformFile = PlatformFile(
+ file = file,
+ macOsBookmarkAccess = macOsBookmarkAccess?.takeIf { it.covers(file) },
+ )
+
+ override fun equals(other: Any?): Boolean = this === other || (other is PlatformFile && file == other.file)
+
+ override fun hashCode(): Int = file.hashCode()
+
+ @JvmSynthetic
+ internal fun startMacOsBookmarkAccess(): Boolean = macOsBookmarkAccess?.start() ?: true
+
+ @JvmSynthetic
+ internal fun stopMacOsBookmarkAccess() {
+ macOsBookmarkAccess?.stop()
+ }
+
+ @JvmSynthetic
+ internal fun releaseMacOsBookmarkAccess() {
+ macOsBookmarkAccess?.release()
+ }
+
+ public actual companion object {
+ @JvmSynthetic
+ internal fun withMacOsBookmarkAccess(
+ file: File,
+ access: MacOsBookmarkAccess,
+ ): PlatformFile = PlatformFile(file, access)
+ }
}
public actual fun PlatformFile(path: Path): PlatformFile =
@@ -34,6 +70,9 @@ public actual fun PlatformFile(path: Path): PlatformFile =
public actual fun PlatformFile.toKotlinxIoPath(): Path =
file.toKotlinxIoPath()
+@PublishedApi
+internal actual fun PlatformFile.withPath(path: Path): PlatformFile = copy(path.toFile())
+
public actual val PlatformFile.extension: String
get() = file.extension
@@ -45,51 +84,99 @@ public actual fun PlatformFile.absolutePath(): String =
public actual inline fun PlatformFile.list(block: (List) -> Unit): Unit =
withScopedAccess {
- val directoryFiles = SystemFileSystem.list(toKotlinxIoPath()).map(::PlatformFile)
+ val directoryFiles = SystemFileSystem.list(toKotlinxIoPath()).map(::withPath)
block(directoryFiles)
}
public actual fun PlatformFile.list(): List =
withScopedAccess {
- SystemFileSystem.list(toKotlinxIoPath()).map(::PlatformFile)
+ SystemFileSystem.list(toKotlinxIoPath()).map(::withPath)
}
@OptIn(ExperimentalTime::class)
-public actual fun PlatformFile.createdAt(): Instant? = runCatching {
- val attributes = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java)
- val timestamp = attributes.creationTime().toMillis()
- return Instant.fromEpochMilliseconds(timestamp)
-}.getOrNull()
+public actual fun PlatformFile.createdAt(): Instant? = withScopedAccess {
+ runCatching {
+ val attributes = Files.readAttributes(file.toPath(), BasicFileAttributes::class.java)
+ Instant.fromEpochMilliseconds(attributes.creationTime().toMillis())
+ }.getOrNull()
+}
@OptIn(ExperimentalTime::class)
-public actual fun PlatformFile.lastModified(): Instant {
- val timestamp = this.file.lastModified()
- return Instant.fromEpochMilliseconds(timestamp)
+public actual fun PlatformFile.lastModified(): Instant = withScopedAccess {
+ Instant.fromEpochMilliseconds(file.lastModified())
}
-public actual fun PlatformFile.mimeType(): MimeType? {
+public actual fun PlatformFile.mimeType(): MimeType? = withScopedAccess {
val mimeTypeValue = try {
Files.probeContentType(file.toPath())
} catch (_: Exception) {
null
}
- return mimeTypeValue?.let(MimeType::parse)
+ mimeTypeValue?.let(MimeType::parse)
}
-public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean = true
+public actual fun PlatformFile.startAccessingSecurityScopedResource(): Boolean = startMacOsBookmarkAccess()
-public actual fun PlatformFile.stopAccessingSecurityScopedResource() {}
+public actual fun PlatformFile.stopAccessingSecurityScopedResource() {
+ stopMacOsBookmarkAccess()
+}
public actual suspend fun PlatformFile.bookmarkData(): BookmarkData = withContext(Dispatchers.IO) {
- BookmarkData(file.path.encodeToByteArray())
+ withScopedAccess {
+ if (Platform.isMac()) {
+ val kind = macOsBookmarkKindForCurrentProcess()
+ BookmarkData(
+ MacOsBookmarkEnvelope(
+ kind = kind,
+ payload = MacOsBookmarks.create(file, kind),
+ ).encode(),
+ )
+ } else {
+ BookmarkData(file.path.encodeToByteArray())
+ }
+ }
}
-public actual fun PlatformFile.releaseBookmark() {}
+public actual fun PlatformFile.releaseBookmark() {
+ releaseMacOsBookmarkAccess()
+}
public actual fun PlatformFile.Companion.fromBookmarkData(
bookmarkData: BookmarkData,
-): PlatformFile {
- val path = bookmarkData.bytes.decodeToString()
- return PlatformFile(Path(path))
+): PlatformFile = resolveBookmarkData(bookmarkData).file
+
+public actual fun PlatformFile.Companion.resolveBookmarkData(
+ bookmarkData: BookmarkData,
+): BookmarkResolution {
+ val envelope = MacOsBookmarkEnvelope.decodeOrNull(bookmarkData.bytes)
+ if (envelope != null) {
+ if (!Platform.isMac()) {
+ throw BookmarkResolutionException(
+ reason = BookmarkResolutionFailure.INCOMPATIBLE_PLATFORM,
+ message = "macOS bookmark data cannot be resolved on this platform",
+ )
+ }
+ val resolved = MacOsBookmarks.resolve(envelope.payload, envelope.kind)
+ return BookmarkResolution(
+ file = resolved.access?.let { PlatformFile.withMacOsBookmarkAccess(resolved.file, it) }
+ ?: PlatformFile(resolved.file),
+ isStale = resolved.isStale,
+ shouldRefresh = resolved.isStale,
+ )
+ }
+ val path = try {
+ bookmarkData.bytes.decodeToString(throwOnInvalidSequence = true)
+ } catch (cause: CharacterCodingException) {
+ throw BookmarkResolutionException(
+ reason = BookmarkResolutionFailure.INVALID_DATA,
+ message = "Legacy JVM bookmark data is not a valid UTF-8 path",
+ cause = cause,
+ )
+ }
+ return BookmarkResolution(
+ file = PlatformFile(Path(path)),
+ isStale = false,
+ shouldRefresh = Platform.isMac(),
+ )
}
diff --git a/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt b/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt
index 81a00869..accc62fd 100644
--- a/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt
+++ b/filekit-core/src/jvmTest/kotlin/io/github/vinceglb/filekit/PlatformFileJvmTest.kt
@@ -1,9 +1,21 @@
+@file:Suppress("ktlint:standard:function-naming", "TestFunctionName")
+
package io.github.vinceglb.filekit
+import com.sun.jna.Platform
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure
import io.github.vinceglb.filekit.mimeType.MimeType
+import kotlinx.coroutines.test.runTest
import kotlinx.io.files.Path
+import java.io.File
+import kotlin.coroutines.Continuation
+import kotlin.io.path.createTempDirectory
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
class PlatformFileJvmTest {
private val resourceDirectory = PlatformFile(Path("src/nonWebTest/resources"))
@@ -35,4 +47,304 @@ class PlatformFileJvmTest {
actual = resourceDirectory.mimeType(),
)
}
+
+ @Test
+ fun PlatformFile_resolveLegacyBookmarkData_recommendsRefreshOnlyOnMacos() {
+ val legacyPath = "src/nonWebTest/resources/hello.txt"
+ val bookmarkData = BookmarkData(legacyPath.encodeToByteArray())
+
+ val resolution = PlatformFile.resolveBookmarkData(bookmarkData)
+
+ assertEquals(expected = legacyPath, actual = resolution.file.path)
+ assertFalse(resolution.isStale)
+ assertEquals(expected = Platform.isMac(), actual = resolution.shouldRefresh)
+ assertEquals(expected = resolution.file, actual = PlatformFile.fromBookmarkData(bookmarkData))
+ }
+
+ @Test
+ fun PlatformFile_bookmarkDataOnMacos_roundTripsCurrentBookmark() = runTest {
+ if (!Platform.isMac()) return@runTest
+
+ val bookmarkData = textFile.bookmarkData()
+ val resolution = PlatformFile.resolveBookmarkData(bookmarkData)
+
+ assertEquals(expected = textFile.file.canonicalPath, actual = resolution.file.file.canonicalPath)
+ assertFalse(resolution.isStale)
+ assertFalse(resolution.shouldRefresh)
+ assertFalse(bookmarkData.bytes.contentEquals(textFile.path.encodeToByteArray()))
+ }
+
+ @Test
+ fun PlatformFile_resolveUnknownBookmarkVersion_throwsTypedFailure() {
+ val unknownVersion = bookmarkEnvelopeHeader().apply {
+ this[lastIndex - 1] = 99
+ }
+
+ val error = assertFailsWith {
+ PlatformFile.resolveBookmarkData(unknownVersion)
+ }
+
+ assertEquals(expected = BookmarkResolutionFailure.UNSUPPORTED_VERSION, actual = error.reason)
+ }
+
+ @Test
+ fun PlatformFile_resolveUnknownBookmarkKind_throwsTypedFailure() {
+ val unknownKind = bookmarkEnvelopeHeader().apply {
+ this[lastIndex] = 99
+ }
+
+ val error = assertFailsWith {
+ PlatformFile.resolveBookmarkData(unknownKind)
+ }
+
+ assertEquals(expected = BookmarkResolutionFailure.INCOMPATIBLE_PLATFORM, actual = error.reason)
+ }
+
+ @Test
+ fun PlatformFile_resolveEmptyBookmarkEnvelope_throwsTypedFailure() {
+ val emptyEnvelope = bookmarkEnvelopeHeader()
+
+ val error = assertFailsWith {
+ PlatformFile.resolveBookmarkData(emptyEnvelope)
+ }
+
+ assertEquals(expected = BookmarkResolutionFailure.INVALID_DATA, actual = error.reason)
+ }
+
+ @Test
+ fun PlatformFile_resolveInvalidLegacyPath_throwsTypedFailure() {
+ val error = assertFailsWith {
+ PlatformFile.resolveBookmarkData(byteArrayOf(0xC3.toByte()))
+ }
+
+ assertEquals(expected = BookmarkResolutionFailure.INVALID_DATA, actual = error.reason)
+ }
+
+ @Test
+ fun PlatformFile_resolveCorruptNativeBookmark_throwsInvalidDataFailure() {
+ if (!Platform.isMac()) return
+ val corruptBookmark = MacOsBookmarkEnvelope(
+ kind = MacOsBookmarkKind.Regular,
+ payload = byteArrayOf(1, 2, 3),
+ ).encode()
+
+ val error = assertFailsWith {
+ PlatformFile.resolveBookmarkData(corruptBookmark)
+ }
+
+ assertEquals(expected = BookmarkResolutionFailure.INVALID_DATA, actual = error.reason)
+ }
+
+ @Test
+ fun PlatformFile_copy_preservesJvmDataClassSurface() {
+ val original = PlatformFile(textFile.file)
+
+ val copied = original.copy()
+
+ assertEquals(expected = original, actual = copied)
+ assertEquals(expected = original.file, actual = copied.component1())
+ }
+
+ @Test
+ fun PlatformFile_preservesCapturedJvmAbi() {
+ val platformFileClass = PlatformFile::class.java
+ platformFileClass.getConstructor(File::class.java)
+ platformFileClass.getMethod("getFile")
+ platformFileClass.getMethod("toString")
+ platformFileClass.getMethod("component1")
+ platformFileClass.getMethod("copy", File::class.java)
+ platformFileClass.getMethod(
+ "copy\$default",
+ platformFileClass,
+ File::class.java,
+ Int::class.javaPrimitiveType,
+ Any::class.java,
+ )
+ platformFileClass.getMethod("hashCode")
+ platformFileClass.getMethod("equals", Any::class.java)
+
+ val companionClass = Class.forName("io.github.vinceglb.filekit.PlatformFile\$Companion")
+ companionClass.getMethod("serializer")
+
+ val facadeClass = Class.forName("io.github.vinceglb.filekit.PlatformFile_jvmKt")
+ facadeClass.getMethod("startAccessingSecurityScopedResource", platformFileClass)
+ facadeClass.getMethod("stopAccessingSecurityScopedResource", platformFileClass)
+ facadeClass.getMethod("bookmarkData", platformFileClass, Continuation::class.java)
+ facadeClass.getMethod("releaseBookmark", platformFileClass)
+ facadeClass.getMethod("fromBookmarkData", companionClass, BookmarkData::class.java)
+ }
+
+ @Test
+ fun PlatformFile_copy_preservesCapabilityOnlyWithinRoot() {
+ val root = createTempDirectory("filekit-bookmark-root").toFile()
+ try {
+ val access = RecordingBookmarkAccess(root)
+ val original = PlatformFile.withMacOsBookmarkAccess(root, access)
+
+ val child = original.copy(File(root, "child"))
+ val outside = original.copy(File(root.parentFile, "outside"))
+
+ child.startAccessingSecurityScopedResource()
+ child.stopAccessingSecurityScopedResource()
+ assertEquals(expected = 1, actual = access.startCount)
+ assertEquals(expected = 1, actual = access.stopCount)
+
+ outside.startAccessingSecurityScopedResource()
+ outside.stopAccessingSecurityScopedResource()
+ assertEquals(expected = 1, actual = access.startCount)
+ assertEquals(expected = 1, actual = access.stopCount)
+ } finally {
+ root.deleteRecursively()
+ }
+ }
+
+ @Test
+ fun PlatformFile_nestedScopedAccess_balancesEverySuccessfulStart() {
+ val file = createTempDirectory("filekit-bookmark-access").toFile()
+ try {
+ val access = RecordingBookmarkAccess(file)
+ val platformFile = PlatformFile.withMacOsBookmarkAccess(file, access)
+
+ platformFile.withScopedAccess {
+ platformFile.withScopedAccess {}
+ }
+
+ assertEquals(expected = 2, actual = access.startCount)
+ assertEquals(expected = 2, actual = access.stopCount)
+ } finally {
+ file.deleteRecursively()
+ }
+ }
+
+ @Test
+ fun PlatformFile_sourceAndSink_keepAccessUntilClose() {
+ val directory = createTempDirectory("filekit-bookmark-stream").toFile()
+ try {
+ val nativeFile = File(directory, "content.txt").apply { writeText("hello") }
+ val access = RecordingBookmarkAccess(directory)
+ val platformFile = PlatformFile.withMacOsBookmarkAccess(nativeFile, access)
+
+ val source = platformFile.source()
+ assertEquals(expected = 1, actual = access.startCount)
+ assertEquals(expected = 0, actual = access.stopCount)
+ source.close()
+ source.close()
+ assertEquals(expected = 1, actual = access.stopCount)
+
+ val sink = platformFile.sink()
+ assertEquals(expected = 2, actual = access.startCount)
+ assertEquals(expected = 1, actual = access.stopCount)
+ sink.close()
+ sink.close()
+ assertEquals(expected = 2, actual = access.stopCount)
+ } finally {
+ directory.deleteRecursively()
+ }
+ }
+
+ @Test
+ fun PlatformFile_releaseBookmark_waitsForOpenStreamsAndRejectsNewAccess() {
+ val directory = createTempDirectory("filekit-bookmark-release").toFile()
+ try {
+ val nativeFile = File(directory, "content.txt").apply { writeText("hello") }
+ val access = RecordingBookmarkAccess(directory)
+ val platformFile = PlatformFile.withMacOsBookmarkAccess(nativeFile, access)
+
+ val source = platformFile.source()
+ platformFile.releaseBookmark()
+
+ assertEquals(expected = 0, actual = access.releaseCount)
+ assertFailsWith {
+ platformFile.startAccessingSecurityScopedResource()
+ }
+
+ source.close()
+
+ assertEquals(expected = 1, actual = access.stopCount)
+ assertEquals(expected = 1, actual = access.releaseCount)
+ platformFile.releaseBookmark()
+ assertEquals(expected = 1, actual = access.releaseCount)
+ } finally {
+ directory.deleteRecursively()
+ }
+ }
+
+ @Test
+ fun PlatformFile_metadataAndBookmarkOperations_balanceScopedAccess() = runTest {
+ val directory = createTempDirectory("filekit-bookmark-metadata").toFile()
+ try {
+ val nativeFile = File(directory, "content.txt").apply { writeText("hello") }
+ val access = RecordingBookmarkAccess(directory)
+ val platformFile = PlatformFile.withMacOsBookmarkAccess(nativeFile, access)
+
+ platformFile.createdAt()
+ platformFile.lastModified()
+ platformFile.mimeType()
+ platformFile.bookmarkData()
+
+ assertEquals(expected = 4, actual = access.startCount)
+ assertEquals(expected = 4, actual = access.stopCount)
+ } finally {
+ directory.deleteRecursively()
+ }
+ }
+
+ @Test
+ fun MacOsBookmarkKind_selectsUsingSandboxEntitlement() {
+ assertEquals(expected = MacOsBookmarkKind.SecurityScoped, actual = macOsBookmarkKind(isSandboxed = true))
+ assertEquals(expected = MacOsBookmarkKind.Regular, actual = macOsBookmarkKind(isSandboxed = false))
+ }
+
+ private fun bookmarkEnvelopeHeader(): ByteArray {
+ val envelope = MacOsBookmarkEnvelope(
+ kind = MacOsBookmarkKind.Regular,
+ payload = byteArrayOf(1),
+ ).encode()
+ return envelope.copyOf(envelope.size - 1)
+ }
+
+ private class RecordingBookmarkAccess(
+ root: File,
+ ) : MacOsBookmarkAccess {
+ private val rootPath = root.canonicalFile.toPath()
+
+ var startCount: Int = 0
+ private set
+
+ var stopCount: Int = 0
+ private set
+
+ var releaseCount: Int = 0
+ private set
+
+ private var released = false
+ private var activeAccesses = 0
+
+ override fun covers(file: File): Boolean = file.canonicalFile.toPath().startsWith(rootPath)
+
+ override fun start(): Boolean {
+ check(!released) { "This security-scoped bookmark has been released" }
+ startCount += 1
+ activeAccesses += 1
+ return true
+ }
+
+ override fun stop() {
+ stopCount += 1
+ activeAccesses -= 1
+ releaseIfDrained()
+ }
+
+ override fun release() {
+ if (released) return
+ released = true
+ releaseIfDrained()
+ }
+
+ private fun releaseIfDrained() {
+ if (released && activeAccesses == 0) {
+ releaseCount += 1
+ }
+ }
+ }
}
diff --git a/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt b/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt
new file mode 100644
index 00000000..0ae34ac4
--- /dev/null
+++ b/filekit-core/src/macosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.macos.kt
@@ -0,0 +1,111 @@
+package io.github.vinceglb.filekit
+
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.alloc
+import kotlinx.cinterop.memScoped
+import kotlinx.cinterop.pointed
+import kotlinx.cinterop.ptr
+import kotlinx.cinterop.reinterpret
+import kotlinx.cinterop.value
+import platform.CoreFoundation.CFBooleanGetValue
+import platform.CoreFoundation.CFBooleanGetTypeID
+import platform.CoreFoundation.CFErrorRefVar
+import platform.CoreFoundation.CFGetTypeID
+import platform.CoreFoundation.CFRelease
+import platform.CoreFoundation.CFStringCreateWithCString
+import platform.CoreFoundation.kCFAllocatorDefault
+import platform.CoreFoundation.kCFStringEncodingUTF8
+import platform.Foundation.NSError
+import platform.Foundation.NSURLBookmarkCreationWithSecurityScope
+import platform.Foundation.NSURLBookmarkResolutionWithSecurityScope
+import platform.Security.SecTaskCopyValueForEntitlement
+import platform.Security.SecTaskCreateFromSelf
+
+@OptIn(ExperimentalForeignApi::class)
+internal actual fun appleBookmarkCreationConfiguration(): AppleBookmarkCreationConfiguration =
+ appleBookmarkCreationConfiguration(isAppSandboxEnabled = readAppSandboxEntitlement())
+
+internal fun appleBookmarkCreationConfiguration(isAppSandboxEnabled: Boolean): AppleBookmarkCreationConfiguration =
+ if (isAppSandboxEnabled) {
+ AppleBookmarkCreationConfiguration(
+ options = NSURLBookmarkCreationWithSecurityScope,
+ kind = MacOsBookmarkKind.SecurityScoped,
+ )
+ } else {
+ AppleBookmarkCreationConfiguration(
+ options = 0u,
+ kind = MacOsBookmarkKind.Regular,
+ )
+ }
+
+internal actual fun encodeAppleBookmarkPayload(
+ payload: ByteArray,
+ configuration: AppleBookmarkCreationConfiguration,
+): ByteArray = MacOsBookmarkEnvelope(
+ kind = requireNotNull(configuration.kind),
+ payload = payload,
+).encode()
+
+internal actual fun decodeAppleBookmarkPayload(bytes: ByteArray): AppleBookmarkPayload {
+ val envelope = MacOsBookmarkEnvelope.decodeOrNull(bytes)
+ if (envelope == null) {
+ return AppleBookmarkPayload(
+ bytes = bytes,
+ resolutionOptions = 0u,
+ isLegacy = true,
+ )
+ }
+ return AppleBookmarkPayload(
+ bytes = envelope.payload,
+ resolutionOptions = envelope.kind.resolutionOptions,
+ isLegacy = false,
+ kind = envelope.kind,
+ )
+}
+
+internal actual fun classifyAppleBookmarkResolutionError(error: NSError?): BookmarkResolutionFailure =
+ if (error?.domain == COCOA_ERROR_DOMAIN && error.code == NS_FILE_READ_CORRUPT_ERROR_CODE) {
+ BookmarkResolutionFailure.INVALID_DATA
+ } else {
+ BookmarkResolutionFailure.RESOURCE_UNAVAILABLE
+ }
+
+private val MacOsBookmarkKind?.resolutionOptions: ULong
+ get() = when (this) {
+ MacOsBookmarkKind.SecurityScoped -> NSURLBookmarkResolutionWithSecurityScope
+ MacOsBookmarkKind.Regular, null -> 0u
+ }
+
+@OptIn(ExperimentalForeignApi::class)
+private fun readAppSandboxEntitlement(): Boolean = memScoped {
+ val task = SecTaskCreateFromSelf(kCFAllocatorDefault)
+ ?: throw IllegalStateException("Could not inspect the App Sandbox entitlement")
+ val entitlement = CFStringCreateWithCString(
+ alloc = kCFAllocatorDefault,
+ cStr = APP_SANDBOX_ENTITLEMENT,
+ encoding = kCFStringEncodingUTF8,
+ ) ?: throw IllegalStateException("Could not create the App Sandbox entitlement name")
+ return try {
+ val error = alloc()
+ val value = SecTaskCopyValueForEntitlement(task, entitlement, error.ptr)
+ if (value == null) {
+ error.value?.let { failure ->
+ CFRelease(failure.reinterpret())
+ throw IllegalStateException("Could not inspect the App Sandbox entitlement")
+ }
+ return false
+ }
+ try {
+ if (CFGetTypeID(value) != CFBooleanGetTypeID()) {
+ throw IllegalStateException("The App Sandbox entitlement is not a Boolean")
+ }
+ CFBooleanGetValue(value.reinterpret())
+ } finally {
+ CFRelease(value)
+ }
+ } finally {
+ CFRelease(entitlement)
+ CFRelease(task)
+ }
+}
diff --git a/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt b/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt
new file mode 100644
index 00000000..ab7b64fb
--- /dev/null
+++ b/filekit-core/src/macosTest/kotlin/io/github/vinceglb/filekit/PlatformFileMacOsBookmarkTest.kt
@@ -0,0 +1,130 @@
+@file:Suppress("ktlint:standard:function-naming", "TestFunctionName")
+
+package io.github.vinceglb.filekit
+
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure
+import io.github.vinceglb.filekit.utils.toByteArray
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.coroutines.test.runTest
+import platform.Foundation.NSURL
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class PlatformFileMacOsBookmarkTest {
+ @Test
+ fun AppleBookmarkConfiguration_selectsUsingSandboxEntitlement() {
+ assertEquals(
+ expected = MacOsBookmarkKind.SecurityScoped,
+ actual = appleBookmarkCreationConfiguration(isAppSandboxEnabled = true).kind,
+ )
+ assertEquals(
+ expected = MacOsBookmarkKind.Regular,
+ actual = appleBookmarkCreationConfiguration(isAppSandboxEnabled = false).kind,
+ )
+ }
+
+ @Test
+ fun PlatformFile_equality_preservesUrlIdentity() {
+ val first = PlatformFile(requireNotNull(NSURL(string = "file:///tmp/filekit-equality?version=1")))
+ val second = PlatformFile(requireNotNull(NSURL(string = "file:///tmp/filekit-equality?version=2")))
+
+ assertEquals(expected = first.nsUrl.path, actual = second.nsUrl.path)
+ assertNotEquals(illegal = first, actual = second)
+ }
+
+ @Test
+ fun PlatformFile_derivedPaths_inheritScopeOnlyWithinRoot() {
+ val root = PlatformFile.withMacOsBookmarkLease(FileKit.projectDir.absoluteFile().nsUrl)
+
+ val child = root / "child"
+ val escaped = root / "../outside"
+
+ assertEquals(expected = root.macOsBookmarkLease, actual = child.macOsBookmarkLease)
+ assertNull(actual = escaped.macOsBookmarkLease)
+ }
+
+ @Test
+ fun PlatformFile_releaseBookmark_rejectsNewScopedAccessForDerivedFiles() {
+ val root = PlatformFile.withMacOsBookmarkLease(FileKit.projectDir.absoluteFile().nsUrl)
+ val child = root / "child"
+
+ root.releaseBookmark()
+
+ assertFailsWith {
+ child.startAccessingSecurityScopedResource()
+ }
+ }
+
+ @Test
+ fun PlatformFile_bookmarkData_roundTripsCurrentBookmark() = runTest {
+ val file = FileKit.projectDir / "src/nonWebTest/resources/hello.txt"
+
+ val bookmarkData = file.bookmarkData()
+ val resolution = PlatformFile.resolveBookmarkData(bookmarkData)
+
+ assertEquals(expected = file.path, actual = resolution.file.path)
+ assertFalse(resolution.isStale)
+ assertFalse(resolution.shouldRefresh)
+ assertFalse(bookmarkData.bytes.contentEquals(file.path.encodeToByteArray()))
+ }
+
+ @OptIn(ExperimentalForeignApi::class)
+ @Test
+ fun PlatformFile_resolveLegacyBookmarkData_recommendsRefresh() {
+ val file = FileKit.projectDir / "src/nonWebTest/resources/hello.txt"
+ val legacyData = requireNotNull(
+ file.nsUrl.bookmarkDataWithOptions(
+ options = 0u,
+ includingResourceValuesForKeys = null,
+ relativeToURL = null,
+ error = null,
+ ),
+ )
+
+ val resolution = PlatformFile.resolveBookmarkData(BookmarkData(legacyData.toByteArray()))
+
+ assertEquals(expected = file.path, actual = resolution.file.path)
+ assertFalse(resolution.isStale)
+ assertTrue(resolution.shouldRefresh)
+ }
+
+ @Test
+ fun PlatformFile_resolveStaleBookmarkData_recommendsRefresh() {
+ val file = FileKit.projectDir / "src/nonWebTest/resources/hello.txt"
+ val resolution = appleBookmarkResolution(
+ payload = AppleBookmarkPayload(
+ bytes = byteArrayOf(1, 2, 3),
+ resolutionOptions = 0u,
+ isLegacy = false,
+ ),
+ nativeResolution = AppleBookmarkNativeResolution(
+ url = file.nsUrl,
+ isStale = true,
+ ),
+ )
+
+ assertEquals(expected = file.path, actual = resolution.file.path)
+ assertTrue(resolution.isStale)
+ assertTrue(resolution.shouldRefresh)
+ }
+
+ @Test
+ fun PlatformFile_resolveCorruptNativeBookmark_throwsInvalidDataFailure() {
+ val corruptBookmark = MacOsBookmarkEnvelope(
+ kind = MacOsBookmarkKind.Regular,
+ payload = byteArrayOf(1, 2, 3),
+ ).encode()
+
+ val error = assertFailsWith {
+ PlatformFile.resolveBookmarkData(corruptBookmark)
+ }
+
+ assertEquals(expected = BookmarkResolutionFailure.INVALID_DATA, actual = error.reason)
+ }
+}
diff --git a/filekit-core/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/PlatformFile.mingw.kt b/filekit-core/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/PlatformFile.mingw.kt
index 130b602c..fc0fad6c 100644
--- a/filekit-core/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/PlatformFile.mingw.kt
+++ b/filekit-core/src/mingwX64Main/kotlin/io/github/vinceglb/filekit/PlatformFile.mingw.kt
@@ -68,6 +68,9 @@ public actual fun PlatformFile(path: Path): PlatformFile =
public actual fun PlatformFile.toKotlinxIoPath(): Path =
windowsPath.path
+@PublishedApi
+internal actual fun PlatformFile.withPath(path: Path): PlatformFile = PlatformFile(path)
+
public actual val PlatformFile.extension: String
get() = name.substringAfterLast('.', "")
@@ -193,9 +196,17 @@ public actual fun PlatformFile.releaseBookmark() {}
public actual fun PlatformFile.Companion.fromBookmarkData(
bookmarkData: BookmarkData,
-): PlatformFile {
+): PlatformFile = resolveBookmarkData(bookmarkData).file
+
+public actual fun PlatformFile.Companion.resolveBookmarkData(
+ bookmarkData: BookmarkData,
+): BookmarkResolution {
val restoredPath = bookmarkData.bytes.decodeToString()
- return PlatformFile(windowsPath = WindowsPath(Path(restoredPath)))
+ return BookmarkResolution(
+ file = PlatformFile(windowsPath = WindowsPath(Path(restoredPath))),
+ isStale = false,
+ shouldRefresh = false,
+ )
}
@OptIn(ExperimentalForeignApi::class)
diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/BookmarkData.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/BookmarkData.kt
index d3cd317e..eaa2251f 100644
--- a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/BookmarkData.kt
+++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/BookmarkData.kt
@@ -8,3 +8,20 @@ package io.github.vinceglb.filekit
public class BookmarkData(
public val bytes: ByteArray,
)
+
+/**
+ * The result of resolving persisted [BookmarkData].
+ *
+ * @property file The file identified by the bookmark.
+ * @property isStale Whether the platform reported that the bookmark data is stale.
+ * @property shouldRefresh Whether FileKit recommends replacing the persisted bookmark data.
+ */
+public class BookmarkResolution(
+ public val file: PlatformFile,
+ public val isStale: Boolean,
+ public val shouldRefresh: Boolean,
+)
+
+internal const val APP_SANDBOX_ENTITLEMENT: String = "com.apple.security.app-sandbox"
+internal const val COCOA_ERROR_DOMAIN: String = "NSCocoaErrorDomain"
+internal const val NS_FILE_READ_CORRUPT_ERROR_CODE: Long = 259
diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt
new file mode 100644
index 00000000..ed48ca30
--- /dev/null
+++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelope.kt
@@ -0,0 +1,69 @@
+package io.github.vinceglb.filekit
+
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure
+
+internal enum class MacOsBookmarkKind(
+ val encodedValue: Byte,
+) {
+ Regular(1),
+ SecurityScoped(2),
+ ;
+
+ companion object {
+ fun fromEncodedValue(value: Byte): MacOsBookmarkKind? = entries.firstOrNull { it.encodedValue == value }
+ }
+}
+
+internal data class MacOsBookmarkEnvelope(
+ val kind: MacOsBookmarkKind,
+ val payload: ByteArray,
+) {
+ fun encode(): ByteArray = MAGIC + byteArrayOf(VERSION, kind.encodedValue) + payload
+
+ companion object {
+ private val MAGIC = byteArrayOf(0) + "FileKitBookmark".encodeToByteArray()
+ private const val VERSION: Byte = 1
+ private val HEADER_SIZE = MAGIC.size + 2
+
+ fun decodeOrNull(bytes: ByteArray): MacOsBookmarkEnvelope? {
+ val magicPrefixSize = minOf(bytes.size, MAGIC.size)
+ if (!bytes.copyOfRange(0, magicPrefixSize).contentEquals(MAGIC.copyOfRange(0, magicPrefixSize))) {
+ return null
+ }
+ if (bytes.size < MAGIC.size) {
+ throw BookmarkResolutionException(
+ reason = BookmarkResolutionFailure.INVALID_DATA,
+ message = "The FileKit bookmark envelope is incomplete",
+ )
+ }
+ if (bytes.size < HEADER_SIZE) {
+ throw BookmarkResolutionException(
+ reason = BookmarkResolutionFailure.INVALID_DATA,
+ message = "The FileKit bookmark envelope is incomplete",
+ )
+ }
+ if (bytes[MAGIC.size] != VERSION) {
+ throw BookmarkResolutionException(
+ reason = BookmarkResolutionFailure.UNSUPPORTED_VERSION,
+ message = "Unsupported FileKit bookmark version: ${bytes[MAGIC.size]}",
+ )
+ }
+ val kind = MacOsBookmarkKind.fromEncodedValue(bytes[MAGIC.size + 1])
+ ?: throw BookmarkResolutionException(
+ reason = BookmarkResolutionFailure.INCOMPATIBLE_PLATFORM,
+ message = "Unsupported FileKit bookmark payload: ${bytes[MAGIC.size + 1]}",
+ )
+ if (bytes.size == HEADER_SIZE) {
+ throw BookmarkResolutionException(
+ reason = BookmarkResolutionFailure.INVALID_DATA,
+ message = "The FileKit bookmark payload is empty",
+ )
+ }
+ return MacOsBookmarkEnvelope(
+ kind = kind,
+ payload = bytes.copyOfRange(HEADER_SIZE, bytes.size),
+ )
+ }
+ }
+}
diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt
index a108e86e..c313c52a 100644
--- a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt
+++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/PlatformFile.nonWeb.kt
@@ -259,6 +259,11 @@ public expect fun PlatformFile.releaseBookmark()
*/
public expect fun PlatformFile.Companion.fromBookmarkData(bookmarkData: BookmarkData): PlatformFile
+/**
+ * Resolves [bookmarkData] and reports whether the persisted representation should be refreshed.
+ */
+public expect fun PlatformFile.Companion.resolveBookmarkData(bookmarkData: BookmarkData): BookmarkResolution
+
/**
* Creates a [PlatformFile] from a byte array representing bookmark data.
*
@@ -267,3 +272,7 @@ public expect fun PlatformFile.Companion.fromBookmarkData(bookmarkData: Bookmark
*/
public fun PlatformFile.Companion.fromBookmarkData(bytes: ByteArray): PlatformFile =
fromBookmarkData(BookmarkData(bytes))
+
+/** Resolves bookmark data stored as a byte array. */
+public fun PlatformFile.Companion.resolveBookmarkData(bytes: ByteArray): BookmarkResolution =
+ resolveBookmarkData(BookmarkData(bytes))
diff --git a/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/exceptions/BookmarkResolutionException.kt b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/exceptions/BookmarkResolutionException.kt
new file mode 100644
index 00000000..cac633dd
--- /dev/null
+++ b/filekit-core/src/nonWebMain/kotlin/io/github/vinceglb/filekit/exceptions/BookmarkResolutionException.kt
@@ -0,0 +1,29 @@
+package io.github.vinceglb.filekit.exceptions
+
+/** The reason persisted bookmark data could not be resolved. */
+public enum class BookmarkResolutionFailure {
+ INVALID_DATA,
+ UNSUPPORTED_VERSION,
+ INCOMPATIBLE_PLATFORM,
+ RESOURCE_UNAVAILABLE,
+}
+
+/** Thrown when persisted bookmark data cannot be resolved. */
+public class BookmarkResolutionException : FileKitException {
+ public val reason: BookmarkResolutionFailure
+
+ public constructor(
+ reason: BookmarkResolutionFailure,
+ message: String,
+ ) : super(message) {
+ this.reason = reason
+ }
+
+ public constructor(
+ reason: BookmarkResolutionFailure,
+ message: String,
+ cause: Throwable,
+ ) : super(message, cause) {
+ this.reason = reason
+ }
+}
diff --git a/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt b/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt
new file mode 100644
index 00000000..1c438bf8
--- /dev/null
+++ b/filekit-core/src/nonWebTest/kotlin/io/github/vinceglb/filekit/MacOsBookmarkEnvelopeTest.kt
@@ -0,0 +1,44 @@
+@file:Suppress("ktlint:standard:function-naming", "TestFunctionName")
+
+package io.github.vinceglb.filekit
+
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionException
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class MacOsBookmarkEnvelopeTest {
+ @Test
+ fun MacOsBookmarkEnvelope_encodeAndDecode_roundTripsPayload() {
+ val expected = MacOsBookmarkEnvelope(
+ kind = MacOsBookmarkKind.SecurityScoped,
+ payload = byteArrayOf(1, 2, 3),
+ )
+
+ val actual = requireNotNull(MacOsBookmarkEnvelope.decodeOrNull(expected.encode()))
+
+ assertEquals(expected = expected.kind, actual = actual.kind)
+ assertTrue(expected.payload.contentEquals(actual.payload))
+ }
+
+ @Test
+ fun MacOsBookmarkEnvelope_decodeLegacyPathStartingWithOldMagic_returnsNull() {
+ val legacyPath = "FKBK-project/file.txt".encodeToByteArray()
+
+ assertNull(MacOsBookmarkEnvelope.decodeOrNull(legacyPath))
+ }
+
+ @Test
+ fun MacOsBookmarkEnvelope_decodeTruncatedCurrentMagic_throwsInvalidData() {
+ val truncatedMagic = byteArrayOf(0) + "FileKit".encodeToByteArray()
+
+ val error = assertFailsWith {
+ MacOsBookmarkEnvelope.decodeOrNull(truncatedMagic)
+ }
+
+ assertEquals(expected = BookmarkResolutionFailure.INVALID_DATA, actual = error.reason)
+ }
+}
diff --git a/filekit-core/src/watchosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.watchos.kt b/filekit-core/src/watchosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.watchos.kt
new file mode 100644
index 00000000..00d71cd8
--- /dev/null
+++ b/filekit-core/src/watchosMain/kotlin/io/github/vinceglb/filekit/AppleBookmarkConfiguration.watchos.kt
@@ -0,0 +1,18 @@
+package io.github.vinceglb.filekit
+
+import io.github.vinceglb.filekit.exceptions.BookmarkResolutionFailure
+import platform.Foundation.NSError
+
+internal actual fun appleBookmarkCreationConfiguration(): AppleBookmarkCreationConfiguration =
+ AppleBookmarkCreationConfiguration(options = 0u, kind = null)
+
+internal actual fun encodeAppleBookmarkPayload(
+ payload: ByteArray,
+ configuration: AppleBookmarkCreationConfiguration,
+): ByteArray = payload
+
+internal actual fun decodeAppleBookmarkPayload(bytes: ByteArray): AppleBookmarkPayload =
+ AppleBookmarkPayload(bytes = bytes, resolutionOptions = 0u, isLegacy = false)
+
+internal actual fun classifyAppleBookmarkResolutionError(error: NSError?): BookmarkResolutionFailure =
+ BookmarkResolutionFailure.RESOURCE_UNAVAILABLE
diff --git a/sample/shared/src/nonWebMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.nonWeb.kt b/sample/shared/src/nonWebMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.nonWeb.kt
index d625e13f..9c1b8900 100644
--- a/sample/shared/src/nonWebMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.nonWeb.kt
+++ b/sample/shared/src/nonWebMain/kotlin/io/github/vinceglb/filekit/sample/shared/util/BookmarkStorage.nonWeb.kt
@@ -8,9 +8,9 @@ import io.github.vinceglb.filekit.delete
import io.github.vinceglb.filekit.div
import io.github.vinceglb.filekit.exists
import io.github.vinceglb.filekit.filesDir
-import io.github.vinceglb.filekit.fromBookmarkData
import io.github.vinceglb.filekit.readBytes
import io.github.vinceglb.filekit.releaseBookmark
+import io.github.vinceglb.filekit.resolveBookmarkData
import io.github.vinceglb.filekit.write
internal actual class BookmarkStorage actual constructor() {
@@ -28,7 +28,13 @@ internal actual class BookmarkStorage actual constructor() {
return try {
val bytes = bookmarkFile.readBytes()
- PlatformFile.fromBookmarkData(bytes)
+ val resolution = PlatformFile.resolveBookmarkData(bytes)
+ if (resolution.shouldRefresh) {
+ runCatching {
+ bookmarkFile write resolution.file.bookmarkData().bytes
+ }
+ }
+ resolution.file
} catch (_: Throwable) {
bookmarkFile.delete(mustExist = false)
null
@@ -54,10 +60,10 @@ internal actual class BookmarkStorage actual constructor() {
try {
val bytes = bookmarkFile.readBytes()
- val bookmarkedFile = PlatformFile.fromBookmarkData(bytes)
+ val bookmarkedFile = PlatformFile.resolveBookmarkData(bytes).file
bookmarkedFile.releaseBookmark()
} catch (_: Throwable) {
- // Ignore failures from stale bookmarks.
+ // Ignore failures from invalid or unavailable bookmarks.
}
bookmarkFile.delete(mustExist = false)