# NAP-FS: files the user picks for a napplet — 2026-10-31

Status: implemented (commons, Android, desktop); unit and shim tests pass. Not yet run on a device.

## Why

PhotoHop ("Fotohop", kind 15129 by `e771af0b…`) declares `["R","fs"]`. A required domain the shell
lacks blocks the launch (NIP-5D: SHOULD reject), so `NappletLaunch.fromManifest` refused it and
the feed card hid Open with no reason given. `fs` is NAP-FS, a draft in
[napplet/naps#88](https://github.com/napplet/naps/pull/88) (`bd1b9e9`, from a fork; the registry
table does not list it yet). PhotoHop only calls `info`, `pickFile`, `pickSaveFile`, `read` and
`write`, reading in 64 KiB ranges and writing in 48 KiB chunks (`replace`, then `append`), sized by
`info().limits`; it tells a cancel from a failure by an error message containing "cancel".

## What Amethyst offers

User-mediated picks only. Nothing of the device is visible until the user picks a file in the
system picker; each pick appears as `/picked/<name>` (made safe for the path model, numbered on a
clash) for the life of that running surface.

| Operation | Behaviour |
| --- | --- |
| `info` | One root, `/picked` ("Picked files"), once something was picked; limits 128 KiB read / 128 KiB write |
| `pickFile` / `pickFiles` | System open picker; grants `read`, plus `write` when asked and the document allows it |
| `pickSaveFile` | System save picker; grants `write` + `create`. The entry is absent until its first write, which starts the file afresh whatever was there |
| `stat`, `read` | On picked entries; ranged reads, `eof`, `size` |
| `write` | `replace` / `append` / `patch`, canonical padded base64 only (`invalid-data` otherwise), `ifAbsent`; `ifRevision` is `unsupported` (no revisions) |
| `list` | `not-a-directory` for a picked file, `not-found` otherwise |
| `pickDirectory`, `mkdir`, `remove`, `move`, `watch` | `unsupported` |
| `unwatch` | no-op success, as the spec allows |

Every path the session did not hand out is `not-found`. Results follow the spec's shape: success
fields and no `error`, or `error` alone; the broker's refusals map to `permission-denied`,
`unsupported` and `io-error`, and a malformed request to `unsupported`.

## Where it lives

- `commons/napplet/fs/`: `NappletFsSession` (the per-surface engine: entries, permissions, limits,
  path model, base64, errors), `NappletFilePort` (what a platform supplies: two pickers and bytes),
  `NappletFsPaths`. Requests/responses in `protocol/`, wire in `NappletProtocolJson`.
- The broker stays app-wide and holds no picks: the host passes its surface's session through
  `NappletRequestRouter.route(…, files)` / `NappletBroker.handle(…, files)`.
- Android: `NappletBrokerService` keeps one session per launch token, dropped when the surface
  releases it. Picks run in the main process (`NappletFsPickerCoordinator` →
  `NappletFsPickerActivity`, SAF `ACTION_OPEN_DOCUMENT` / `ACTION_CREATE_DOCUMENT`, whose grant
  outlives the activity); `AndroidNappletFilePort` reads and writes through the `ContentResolver`
  (`wt`/`rw` for replace, `rw`/`wa` for append with a cut-back on failure, `rw` for patch). The
  app's own providers are refused, as for a page's file input (`NappletPickedFiles`).
- Desktop: `CefNappletSession` holds one session per window; `DesktopNappletFilePort` uses the AWT
  `FileDialog`, replaces atomically through a `.part` file beside the real file (a symlink stays a
  link, permissions are kept; in place where no file can be made there), and cuts a failed
  append back.
- Shim: `window.napplet.fs`, base64 passed through (bytes given to `write` are encoded).

## Policy

- Consent: only picks ask (`FS` capability, the usual ledger and first-connect bulk grant). Reads
  and writes act on what a pick handed over, so they never prompt.
- An app inside a note never opens a picker, standing grant or not: the pick is refused
  (`policy-denied`) and recorded in `NappletInlineAsks`, so the note offers to open the app.
- One picker at a time (`conflict`); a multi-select gets only the room left under 256 entries; after two dismissals in a row the napplet waits 3 s, doubling
  to 60 s (`policy-denied`), so it can't trap the user in a reopening picker.
- Android holds `fs.pick*` and `fs.write` while the surface isn't in front
  (`NappletActingRequests`), so a background napplet can't pop a picker or keep writing.
- Limits are 128 KiB per read and write: base64 in a Bundle string stays well under Binder's
  transaction limit.

## Open

- Device check of the SAF modes on real providers (Downloads, Drive, a USB volume), and of the
  picker appearing over the full-screen host.
- `pickDirectory`, `list`, `mkdir`, `remove`, `move`, `watch` if a napplet needs them.
- Revisit when NAP-FS lands in the registry or changes its wire.
