Skip to content
GitHub

File shelf

The shelf lets users drop files onto the notch; they collect in a persistent ShelfStore and can be dragged back out to Finder or another app. It lives in the NookComponents product - so add a dependency on that product when you want it, alongside NookApp.

When to use it

  • You want a quick “park this file somewhere I can grab later” surface.
  • You want drops to land in a persistent place that survives a relaunch rather than triggering a one-shot import.
  • You want drag-out from the notch back to other apps without writing the promise plumbing yourself.

If you only want to handle file drops - kick off an import, run a script, add a row to your own model - skip the shelf and just wire NookConfiguration.onFileDrop. The shelf is one consumer of that hook, not the only one.

Minimal setup

import NookApp
import NookComponents
import SwiftUI
NookApp.main {
let shelf = ShelfStore()
var configuration = NookConfiguration()
configuration.setHome { NookShelfView(store: shelf) }
configuration.onFileDrop = { urls in shelf.accept(urls) }
return configuration
}

NookApp.main { ... } builds the configuration on the main actor, which is where the @MainActor-isolated ShelfStore must be constructed. Wire ShelfStore.accept into NookConfiguration.onFileDrop so dropped files reach the store; render the shelf with NookShelfView. That’s the whole minimal integration.

The full working example is at Examples/ShelfNook/main.swift.

Key types

ShelfStore

The observable model. Persists itself as encoded bookmarks under a UserDefaults key ("nook.shelf.items" by default) and reloads on the next launch, healing stale bookmarks and dropping items whose files have genuinely disappeared.

public init(
persistenceKey: String = "nook.shelf.items",
defaults: UserDefaults = .standard,
acceptanceMode: AcceptanceMode = .lenient
)

The store exposes:

  • items: [ShelfItem] - oldest first.
  • accept(_ urls: [URL]) -> Bool - the drop sink. Returns true when at least one file was admitted, which is exactly the shape NookConfiguration.onFileDrop wants. Skips files already on the shelf (compared by resolved path).
  • remove(_:) / remove(id:) / clear() - removal API. The chip view uses these via its hover-revealed remove button.
  • purgeMissing() - drops items whose underlying file is genuinely gone. The store calls this for you on init; call it again yourself if you want to reconcile after the shelf surface re-appears.

In a multi-module host, pass the active module’s isolated suite so two modules can’t collide on shelf keys:

let shelf = ShelfStore(defaults: context.defaults)

NookShelfView

The surface. Renders an empty-state prompt when the store is empty, otherwise a horizontal row of file chips with a header and a Clear button. Each chip shows the file icon and name, supports drag-out via a file-promise drag source, and reveals a remove button on hover. Tints come from \.nookResolvedTheme, so the shelf inherits whatever palette the host configured.

ShelfRuntime

Small capability probes. The only one most hosts care about:

ShelfRuntime.isSandboxed // true under the App Sandbox

The store consults this internally; you only read it directly if you are making your own sandbox-aware decisions on top of the shelf.

Sandbox behavior

The shelf works under the App Sandbox and outside it - but a drop’s durability depends on whether the framework can capture a scoped bookmark for the file. The store records the kind of bookmark it captured per item and uses that to make purge and resolve decisions correctly.

ShelfStore.AcceptanceMode lets you pick how strict to be on capture:

  • .lenient (the default). Accept any drop. Under the App Sandbox, a non-scoped capture is preserved across the current session but will not resolve in a future launch. The store logs one diagnostic per process the first time this happens (search log show for the subsystem dev.opennook.shelf).
  • .strict. Under the App Sandbox, drop any item whose scoped bookmark capture failed. Use this when you’d rather a drop visibly fail than land in a state that won’t survive a relaunch.
let shelf = ShelfStore(acceptanceMode: .strict)

On reload, the store drops a .scoped item only when at least one other .scoped item on the shelf did resolve in this launch. If every resolution fails - indistinguishable from a sandboxed host that lost its grants entirely

  • nothing is purged; the shelf is preserved for a future launch that can resolve. .nonScoped and .unknown items are never purged on resolution failure for the same reason.

You don’t need to write any of this logic - it is all in ShelfStore.loadAndReconcile and purgeMissing. The point of mentioning it is that a sandboxed shelf that looks “empty” after a relaunch is usually a lost grant rather than data loss, and the store is designed not to compound the loss by deleting the records.

Drag-out

NookShelfView registers a file-promise drag source on every chip. The promise model is what makes drag-out work from the notch’s non-activating panel: receivers that demand file promises (some Finder paths, some sandboxed sinks) get them, and the file copy only runs when the receiver requests data

  • with security scope held around it under the sandbox.

You get this for free by rendering NookShelfView. If you build your own shelf surface you’ll want to reach into Sources/NookComponents/Shelf/ for the drag-source helper rather than re-implement the bracketing.

Pitfalls

Construct ShelfStore on the main actor

ShelfStore is @MainActor-isolated. The NookApp.main { ... } builder runs on the main actor, so constructing the store inside the closure is the ergonomic path. If you assemble the configuration outside the builder, hop to the main actor first.

Don’t forget the onFileDrop wire-up

Constructing a ShelfStore and rendering NookShelfView is not enough on its own - the drop sink is NookConfiguration.onFileDrop. The default configuration leaves it nil, which rejects every drop. The one-liner configuration.onFileDrop = { urls in shelf.accept(urls) } is what connects them.

Sandboxed reload != data loss

A sandboxed shelf can look empty on relaunch when the host has lost its scoped grants. That isn’t a bug - the store deliberately preserves non-resolvable items so a later launch (or a re-add that promotes them to .scoped) can recover them. If you want a guarantee that drops either work durably or visibly fail at drop time, use acceptanceMode: .strict.

See also

  • Examples/ShelfNook/main.swift - the working pattern this guide mirrors.
  • File drops on the chrome
    • the lifecycle hook the shelf consumes.
  • Sources/NookComponents/Shelf/ - the component sources, including ShelfItem and the drag-source helper.