@banou/ponyfill 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,6 +71,59 @@ Cache is the thing that can be fetched again; a caller that never reclaims becau
71
71
  pressure is the failure this replaces. The ceiling still follows the platform DOWNWARD, and is never
72
72
  reported below the bytes already held, so `quota - usage` reaches zero and never goes negative.
73
73
 
74
+ The ceiling is held per realm and in memory, and it **lets go when the origin's persistence changes**.
75
+ A granted `persist()` moved the reported quota from 12 GB to 3.97 TB on Firefox (measured
76
+ 2026-09-01), so the figure learned before that is worse than useless afterwards. The release is keyed
77
+ on the state rather than on the call, because `persist()` only exists on the main thread: a worker
78
+ that latched the old ceiling and could never call it would go on deciding what to delete from a
79
+ number that no longer exists.
80
+
81
+ ### `permissions`
82
+
83
+ `query()` has four ways of behaving and only one of them is an answer: the engine may have no
84
+ Permissions API, may reject a name it does not implement, may **throw synchronously** for one (which
85
+ a `.catch` on the returned promise never sees), or may answer a real state.
86
+
87
+ All three non-answers collapse to `'prompt'`. Not `'denied'`, because an engine that cannot be asked
88
+ has not refused anything, and treating silence as refusal costs the person a control that might have
89
+ worked.
90
+
91
+ ### `showOpenFilePicker`, `showDirectoryPicker`, `showSaveFilePicker`
92
+
93
+ Measured 2026-09-03 across Chromium 149, Firefox 151 and WebKit 26:
94
+
95
+ | | Chromium | Firefox | WebKit |
96
+ | --- | --- | --- | --- |
97
+ | the three pickers | present | **absent** | **absent** |
98
+ | `FileSystemHandle` and friends as globals | present | present | **absent** |
99
+ | `<input>.webkitdirectory` and its `cancel` event | yes | yes | yes |
100
+ | an object holding its methods as own properties, cloned | `DataCloneError` | `DataCloneError` | `DataCloneError` |
101
+ | the same object with its methods on a prototype | clones, silently | clones, silently | clones, silently |
102
+ | `<input webkitdirectory>` file order, same tree | `a`, `sub/b` | `sub/b`, `a` | `sub/b`, `a` |
103
+
104
+ **Reading always works, and always answers handles.** Where the platform has a picker it is used and
105
+ its handles come back untouched. Where it does not, an `<input type="file">` is opened and what comes
106
+ back is wrapped in the same shape, so a caller has one call and one return type instead of a
107
+ `FileSystemFileHandle | File` union threaded through everything downstream.
108
+
109
+ **Writing is refused rather than faked.** `showSaveFilePicker` has no fallback, `showDirectoryPicker`
110
+ refuses `mode: 'readwrite'` where there is no native picker, and a wrapped handle's
111
+ `createWritable()` throws. There is no way to write to a chosen location without the platform's
112
+ picker.
113
+
114
+ **Refusals happen before the gesture is spent**, and are named `NotAllowedError` so a caller can tell
115
+ them from the `AbortError` the platform throws when the person cancels. A caller has one transient
116
+ activation, and a picker that rejects at call time has already spent part of it.
117
+
118
+ **A wrapped handle cannot be persisted, and says so loudly.** This is the one difference that cannot
119
+ be absorbed, so what is picked is how it fails. A native handle survives `structuredClone` and comes
120
+ back out of IndexedDB still usable; a wrapper around a `File` cannot, because a snapshot is not an
121
+ entry on a disk. Left as an ordinary object it would clone *successfully* and come back with its
122
+ prototype gone and every method with it, failing after a reload with nothing pointing at the pick. So
123
+ every wrapped handle carries its methods as own properties, which makes the store throw
124
+ `DataCloneError` at the moment of the mistake: one `.catch` where it matters instead of a capability
125
+ probe at every call site.
126
+
74
127
  ## Adding to it
75
128
 
76
129
  If you hit something that behaves differently between engines, or differently from its own
@@ -1,9 +1,15 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  //#region src/file-system.ts
3
+ var NO_SAVE_PICKER = "this browser has no file save picker";
4
+ var NO_FRAMED_PICKER = "a cross origin frame cannot show a file save picker";
5
+ var NO_DOCUMENT = "there is no document here to open a file picker from";
6
+ var NO_WRITE = "this file was opened from a picker that cannot write, so there is nothing to write to";
7
+ var NO_WRITE_FOLDER = "this browser cannot grant write access to a chosen folder";
8
+ var refuse = (message) => new DOMException(message, "NotAllowedError");
3
9
  /**
4
10
  * Whether this document is framed by another origin.
5
11
  *
6
- * Chromium exposes the picker either way and refuses it at call time, so a property probe says
12
+ * Chromium exposes the pickers either way and refuses them at call time, so a property probe says
7
13
  * nothing. A same origin ancestor answers `location.origin`; a cross origin one throws, and so does
8
14
  * an opaque origin, which is the case a sandboxed frame presents.
9
15
  */
@@ -18,11 +24,242 @@ var framedByAnotherOrigin = () => {
18
24
  return true;
19
25
  }
20
26
  };
27
+ /**
28
+ * The `accept` attribute for an `<input>`, from the picker's own `types`.
29
+ *
30
+ * Both halves of each entry go in. The attribute takes MIME types and extensions in one comma
31
+ * separated list, and an engine that does not recognise one of them ignores that one rather than the
32
+ * whole attribute, so listing both is strictly better than choosing. `*` patterns are dropped: they
33
+ * are what `excludeAcceptAllOption: false` already means, and an `accept` of `*` filters nothing
34
+ * while making the dialog claim it does.
35
+ */
36
+ var acceptFrom = (types) => {
37
+ const out = /* @__PURE__ */ new Set();
38
+ for (const type of types ?? []) for (const [mime, extensions] of Object.entries(type.accept ?? {})) {
39
+ if (mime && !mime.includes("*")) out.add(mime);
40
+ for (const extension of extensions) out.add(extension);
41
+ }
42
+ return [...out].join(",");
43
+ };
44
+ /**
45
+ * One pick through a detached `<input type="file">`, resolving the files or rejecting like a cancel.
46
+ *
47
+ * The `cancel` event is what makes this a promise that always settles, and it is why this fallback is
48
+ * worth having at all rather than being a hazard: measured present on all three engines above. An
49
+ * engine without it would leave a picker that was dismissed pending forever, which is worse than not
50
+ * offering one.
51
+ *
52
+ * NEEDS THE CALLER'S TRANSIENT ACTIVATION, exactly as the platform picker does. `click()` on a file
53
+ * input opens nothing without a gesture, so this has to be reached synchronously from the handler,
54
+ * and an `await` before it loses that. The same rule the native picker has, for the same reason.
55
+ *
56
+ * The input is attached and removed rather than left detached: a detached input's `click()` is
57
+ * ignored by some engines, and leaving it in the document would leave one element per pick behind.
58
+ */
59
+ var pickThroughInput = (setup) => {
60
+ const document = globalThis.document;
61
+ if (!document?.body) return Promise.reject(refuse(NO_DOCUMENT));
62
+ return new Promise((resolve, reject) => {
63
+ const input = document.createElement("input");
64
+ input.type = "file";
65
+ input.style.display = "none";
66
+ input.setAttribute("aria-hidden", "true");
67
+ setup(input);
68
+ const settle = (finish) => {
69
+ input.remove();
70
+ finish();
71
+ };
72
+ input.addEventListener("change", () => settle(() => resolve([...input.files ?? []])), { once: true });
73
+ input.addEventListener("cancel", () => settle(() => reject(new DOMException("the file picker was closed", "AbortError"))), { once: true });
74
+ document.body.append(input);
75
+ input.click();
76
+ });
77
+ };
78
+ var handles = /* @__PURE__ */ new WeakMap();
79
+ /**
80
+ * Entries in a fixed order, which the platform does not promise and the engines do not agree on.
81
+ *
82
+ * Measured above: the same tree comes out of `<input webkitdirectory>` in one order on Chromium and
83
+ * the opposite on Firefox and WebKit. Sorted by code unit rather than `localeCompare`, because a
84
+ * locale sensitive sort is one more thing that differs between two machines running the same code.
85
+ */
86
+ var ordered = (children) => [...children].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
87
+ var fileHandleFor = (node) => {
88
+ return {
89
+ kind: "file",
90
+ name: node.name,
91
+ /**
92
+ * The same `File` every time, where the platform hands back a fresh one.
93
+ *
94
+ * A `File` from an input is a snapshot taken when it was picked. It cannot be re-read from disk,
95
+ * so `lastModified` never moves and a staleness check across it can never fire. What it does do
96
+ * is throw on READ once the file underneath has changed, so a pass over the bytes still refuses
97
+ * to produce a mixture of two versions; it just reports it as a failed read rather than a
98
+ * changed file.
99
+ */
100
+ getFile: async () => node.file,
101
+ createWritable: async () => {
102
+ throw refuse(NO_WRITE);
103
+ },
104
+ isSameEntry: async (other) => other === handles.get(node)
105
+ };
106
+ };
107
+ var directoryHandleFor = (node) => {
108
+ const child = (name) => node.children.get(name);
109
+ const entries = async function* () {
110
+ for (const [name, entry] of ordered(node.children)) yield [name, handleFor(entry)];
111
+ };
112
+ return {
113
+ kind: "directory",
114
+ name: node.name,
115
+ entries,
116
+ keys: async function* () {
117
+ for (const [name] of ordered(node.children)) yield name;
118
+ },
119
+ values: async function* () {
120
+ for (const [, entry] of ordered(node.children)) yield handleFor(entry);
121
+ },
122
+ [Symbol.asyncIterator]: entries,
123
+ getFileHandle: async (name, options) => {
124
+ if (options?.create) throw refuse(NO_WRITE);
125
+ const found = child(name);
126
+ if (!found) throw new DOMException(`there is no ${name} here`, "NotFoundError");
127
+ if (found.kind !== "file") throw new DOMException(`${name} is a directory`, "TypeMismatchError");
128
+ return handleFor(found);
129
+ },
130
+ getDirectoryHandle: async (name, options) => {
131
+ if (options?.create) throw refuse(NO_WRITE);
132
+ const found = child(name);
133
+ if (!found) throw new DOMException(`there is no ${name} here`, "NotFoundError");
134
+ if (found.kind !== "directory") throw new DOMException(`${name} is a file`, "TypeMismatchError");
135
+ return handleFor(found);
136
+ },
137
+ removeEntry: async () => {
138
+ throw refuse(NO_WRITE);
139
+ },
140
+ /** The path from here down to a handle in this tree, or null where it is not in it. */
141
+ resolve: async (descendant) => {
142
+ const search = (from, path) => {
143
+ for (const [name, entry] of ordered(from.children)) {
144
+ if (handles.get(entry) === descendant) return [...path, name];
145
+ if (entry.kind === "directory") {
146
+ const deeper = search(entry, [...path, name]);
147
+ if (deeper) return deeper;
148
+ }
149
+ }
150
+ return null;
151
+ };
152
+ return handles.get(node) === descendant ? [] : search(node, []);
153
+ },
154
+ isSameEntry: async (other) => other === handles.get(node)
155
+ };
156
+ };
157
+ var handleFor = (node) => {
158
+ const made = handles.get(node);
159
+ if (made) return made;
160
+ const fresh = node.kind === "file" ? fileHandleFor(node) : directoryHandleFor(node);
161
+ handles.set(node, fresh);
162
+ return fresh;
163
+ };
164
+ /**
165
+ * The picked folder, rebuilt from the flat list an input hands over.
166
+ *
167
+ * `webkitRelativePath` is the whole tree already flattened: `Pack/Subs/E01.ass` for a folder called
168
+ * `Pack`. The first segment is the folder's own name, so it becomes the root's name and is dropped
169
+ * from every path under it, which is what makes this the same shape `showDirectoryPicker` returns.
170
+ *
171
+ * AN EMPTY FOLDER LOSES ITS NAME, and nothing can be done about that. The name is only ever learned
172
+ * from a file's path, so a folder with nothing in it comes back named `''`. It is a real pick rather
173
+ * than a cancel, since `change` fired, and reporting it as an empty directory is the closest true
174
+ * answer available.
175
+ *
176
+ * A name used twice, once as a file and once as a folder, resolves to the folder, because a path
177
+ * continuing through it proves it is one. Two files with the same path keep the last, which is what
178
+ * a map does and what re-picking the same tree would do anyway.
179
+ */
180
+ var treeFrom = (files) => {
181
+ const root = {
182
+ kind: "directory",
183
+ name: "",
184
+ children: /* @__PURE__ */ new Map()
185
+ };
186
+ for (const file of files) {
187
+ const segments = (file.webkitRelativePath || file.name).split("/").filter(Boolean);
188
+ if (!segments.length) continue;
189
+ const rooted = Boolean(file.webkitRelativePath) && segments.length > 1;
190
+ if (rooted && !root.name) root.name = segments[0];
191
+ const path = rooted ? segments.slice(1) : segments;
192
+ let level = root.children;
193
+ for (const name of path.slice(0, -1)) {
194
+ const existing = level.get(name);
195
+ const directory = existing?.kind === "directory" ? existing : {
196
+ kind: "directory",
197
+ name,
198
+ children: /* @__PURE__ */ new Map()
199
+ };
200
+ if (existing !== directory) level.set(name, directory);
201
+ level = directory.children;
202
+ }
203
+ const leaf = path[path.length - 1];
204
+ level.set(leaf, {
205
+ kind: "file",
206
+ name: leaf,
207
+ file
208
+ });
209
+ }
210
+ return root;
211
+ };
212
+ /**
213
+ * Same name and signature, refusing before the gesture is spent and never faking a save.
214
+ *
215
+ * The one picker with no fallback. Writing to a location somebody chose needs the platform's picker,
216
+ * and what an app does instead is a product decision rather than a shim: see the header.
217
+ */
21
218
  var showSaveFilePicker = async (options) => {
22
219
  const picker = globalThis.showSaveFilePicker;
23
- if (!picker) throw new DOMException("this browser has no file save picker", "NotAllowedError");
24
- if (framedByAnotherOrigin()) throw new DOMException("a cross origin frame cannot show a file save picker", "NotAllowedError");
220
+ if (!picker) throw refuse(NO_SAVE_PICKER);
221
+ if (framedByAnotherOrigin()) throw refuse(NO_FRAMED_PICKER);
25
222
  return picker(options);
26
223
  };
224
+ /**
225
+ * Same name and signature, answering file handles on every engine.
226
+ *
227
+ * The native picker is preferred wherever it can actually be shown, and its handles come back
228
+ * UNTOUCHED: they are structured cloneable, they can be re-granted after a reload, and wrapping them
229
+ * would take exactly that away. A cross origin frame is skipped rather than tried, since the platform
230
+ * refuses there and refusing costs part of the click the fallback still needs.
231
+ */
232
+ var showOpenFilePicker = async (options = {}) => {
233
+ const picker = globalThis.showOpenFilePicker;
234
+ if (picker && !framedByAnotherOrigin()) return picker(options);
235
+ const accept = acceptFrom(options.types);
236
+ return (await pickThroughInput((input) => {
237
+ input.multiple = options.multiple === true;
238
+ if (accept) input.accept = accept;
239
+ })).map((file) => handleFor({
240
+ kind: "file",
241
+ name: file.name,
242
+ file
243
+ }));
244
+ };
245
+ /**
246
+ * Same name and signature, answering a directory handle wherever reading one is possible.
247
+ *
248
+ * `mode: 'readwrite'` is refused where there is no native picker, and that refusal is the honest
249
+ * answer rather than a gap: an `<input>` hands over copies of bytes and there is no route from one
250
+ * back to the folder it came from. Refusing at the ask beats handing back a handle whose every write
251
+ * fails later.
252
+ */
253
+ var showDirectoryPicker = async (options = {}) => {
254
+ const picker = globalThis.showDirectoryPicker;
255
+ if (picker && !framedByAnotherOrigin()) return picker(options);
256
+ if (options.mode === "readwrite") throw refuse(NO_WRITE_FOLDER);
257
+ return handleFor(treeFrom(await pickThroughInput((input) => {
258
+ input.webkitdirectory = true;
259
+ input.multiple = true;
260
+ })));
261
+ };
27
262
  //#endregion
263
+ exports.showDirectoryPicker = showDirectoryPicker;
264
+ exports.showOpenFilePicker = showOpenFilePicker;
28
265
  exports.showSaveFilePicker = showSaveFilePicker;
@@ -1,46 +1,129 @@
1
1
  /**
2
- * The File System Access pickers, with the same names and one behaviour picked.
2
+ * The File System Access pickers, with the same names and one behaviour picked for each.
3
+ *
4
+ * ## What was measured, on what, and when
5
+ *
6
+ * 2026-09-03, one machine, three engines driven headless through Playwright, same probe in each:
7
+ *
8
+ * | | Chromium 149 | Firefox 151 | WebKit 26 |
9
+ * | --- | --- | --- | --- |
10
+ * | `showOpenFilePicker` / `showDirectoryPicker` / `showSaveFilePicker` | all present | all absent | all absent |
11
+ * | `FileSystemHandle` and friends as globals | present | present | ABSENT |
12
+ * | `queryPermission` on a native handle | function | undefined | no handles to ask |
13
+ * | `<input>.webkitdirectory` | yes | yes | yes |
14
+ * | the input's `cancel` event | yes | yes | yes |
15
+ * | an object holding its methods as OWN properties, cloned | DataCloneError | DataCloneError | DataCloneError |
16
+ * | the same object with its methods on a PROTOTYPE, cloned | clones, silently | clones, silently | clones, silently |
17
+ * | `<input webkitdirectory>` file order, same tree | `a.bin`, `sub/b.bin` | `sub/b.bin`, `a.bin` | `sub/b.bin`, `a.bin` |
18
+ *
19
+ * Four of those rows decide something below, and each is noted where it does.
3
20
  *
4
21
  * ## The divergence
5
22
  *
6
- * `showSaveFilePicker` exists on the window in Chromium whether or not it can actually be used, and
7
- * refuses at CALL time in two cases that a property probe cannot see:
23
+ * Two engines of three have NO picker at all, so an app that wants files from a person carries a
24
+ * second route for them: an `<input type="file">`, a `File` where the others have a handle, and a
25
+ * union type threaded through everything downstream. That union is the cost, and it is paid in every
26
+ * file the bytes pass through rather than at the boundary where the difference actually is.
27
+ *
28
+ * `showSaveFilePicker` diverges a second way, which is why it is the one entry here with no
29
+ * fallback. Chromium exposes it whether or not it can be used and refuses at CALL time in two cases a
30
+ * property probe cannot see: a cross origin ancestor frame ("Cross origin sub frames aren't allowed
31
+ * to show a file picker") and no transient activation.
32
+ *
33
+ * ## The picks
8
34
  *
9
- * - a cross origin ancestor frame, which fails with "Cross origin sub frames aren't allowed to show
10
- * a file picker"
11
- * - no transient activation, which fails with "Must be handling a user gesture to show a file picker"
35
+ * READING ALWAYS WORKS, and always answers handles. `showOpenFilePicker` and `showDirectoryPicker`
36
+ * open the platform's picker where there is one and hand back its handles untouched; where there is
37
+ * none they open an `<input type="file">` and wrap what comes back in the same shape. One call, one
38
+ * return type, no branch in the caller.
12
39
  *
13
- * Firefox does not expose it at all, so `'showSaveFilePicker' in window` answers a different question
14
- * on each engine: on one it means "might work", on the other "will never work".
40
+ * WRITING IS REFUSED RATHER THAN FAKED. `showSaveFilePicker` has no fallback, `showDirectoryPicker`
41
+ * refuses `mode: 'readwrite'` where it has no native picker, and a wrapped handle's
42
+ * `createWritable()` throws. There is no way to write to a chosen location without the platform's
43
+ * picker, and a download that appeared in someone's downloads folder instead would be a different
44
+ * thing wearing the same name.
15
45
  *
16
- * ## The pick
46
+ * REFUSALS HAPPEN BEFORE THE GESTURE IS SPENT, and are named so a caller can tell them from a
47
+ * cancel. A caller with a fallback chain has one transient activation, and a picker that rejects at
48
+ * call time has already consumed part of it, so the fallback reached for next can fail too.
49
+ * `NotAllowedError` for every refusal and never `AbortError`, because `AbortError` is what the
50
+ * platform throws when the PERSON cancels: a caller that cannot tell those apart reports a failure
51
+ * for something somebody chose to do.
17
52
  *
18
- * REJECT BEFORE THE ACTIVATION IS SPENT, with a rejection a caller can tell apart from a cancel.
53
+ * A WRAPPED HANDLE CANNOT BE PERSISTED, AND SAYS SO LOUDLY. This is the one difference that cannot be
54
+ * absorbed, so what is picked is how it FAILS. A native handle survives `structuredClone` and comes
55
+ * back out of IndexedDB still usable, which is the whole reason handles are worth having; a wrapper
56
+ * around a `File` cannot, because the thing it refers to is a snapshot rather than an entry on a
57
+ * disk. Left as an ordinary object it would clone SUCCESSFULLY and come back with its prototype gone
58
+ * and every method with it, so the app would store it, reload, and fail somewhere else entirely with
59
+ * nothing pointing back here. Measured on all three engines above, both halves of that.
19
60
  *
20
- * That ordering is the whole of it. A caller with a fallback chain has one transient activation to
21
- * spend, and a picker that rejects at call time has already consumed part of it, so the fallback the
22
- * caller reaches for next can fail too. Rejecting before the call keeps the gesture intact.
61
+ * So every wrapped handle carries its methods as OWN properties, which makes `structuredClone` and
62
+ * `IDBObjectStore.put` throw `DataCloneError` at the moment of the mistake. `await set(key, handle)`
63
+ * rejecting where the browser cannot remember it is the honest shape, and it is one `.catch` at the
64
+ * one place that cares rather than a capability probe at every call site.
23
65
  *
24
- * `NotAllowedError` for both refusals rather than `AbortError`, because `AbortError` is what the
25
- * platform throws when the PERSON cancels, and a caller that cannot tell those apart shows an error
26
- * for something the person chose to do. Ripple's `isSaveCancelled` matches on `AbortError`, so this
27
- * distinction is load bearing rather than tidy.
66
+ * WHAT IS NOT ABSORBED, deliberately:
28
67
  *
29
- * WHAT IS NOT ABSORBED, deliberately. The fallback chain itself stays with the caller: an anchor
30
- * download, a service worker sink, or holding bytes in memory are not platform names and choosing
31
- * between them is a product decision about what a page does when it cannot save. A ponyfill that
32
- * silently downloaded something instead of opening a picker would be lying about which API it is.
68
+ * - the fallback chain for saving. An anchor download, a service worker sink, or holding bytes in
69
+ * memory are not platform names, and choosing between them is a product decision about what a page
70
+ * does when it cannot save.
71
+ * - `queryPermission` and `requestPermission` on a handle. They are Chromium's alone, absent from
72
+ * Firefox even on its own native handles, and they are methods on an object the caller already
73
+ * holds rather than a name this package could export without inventing one.
74
+ * - re-opening. Nothing here makes a snapshot durable. An app that needs the bytes after a reload
75
+ * has to copy them somewhere it owns, which spends the origin's quota and is therefore its
76
+ * decision to make rather than this package's to make silently.
33
77
  */
78
+ type FilePickerAcceptType = {
79
+ description?: string;
80
+ accept: Record<string, string[]>;
81
+ };
34
82
  type SaveFilePickerOptions = {
35
83
  suggestedName?: string;
36
- types?: {
37
- description?: string;
38
- accept: Record<string, string[]>;
39
- }[];
84
+ types?: FilePickerAcceptType[];
85
+ excludeAcceptAllOption?: boolean;
86
+ id?: string;
87
+ startIn?: unknown;
88
+ };
89
+ type OpenFilePickerOptions = {
90
+ multiple?: boolean;
91
+ types?: FilePickerAcceptType[];
40
92
  excludeAcceptAllOption?: boolean;
41
93
  id?: string;
42
94
  startIn?: unknown;
43
95
  };
96
+ type DirectoryPickerOptions = {
97
+ id?: string;
98
+ mode?: 'read' | 'readwrite';
99
+ startIn?: unknown;
100
+ };
44
101
  type SaveFilePicker = (options?: SaveFilePickerOptions) => Promise<FileSystemFileHandle>;
102
+ type OpenFilePicker = (options?: OpenFilePickerOptions) => Promise<FileSystemFileHandle[]>;
103
+ type DirectoryPicker = (options?: DirectoryPickerOptions) => Promise<FileSystemDirectoryHandle>;
104
+ /**
105
+ * Same name and signature, refusing before the gesture is spent and never faking a save.
106
+ *
107
+ * The one picker with no fallback. Writing to a location somebody chose needs the platform's picker,
108
+ * and what an app does instead is a product decision rather than a shim: see the header.
109
+ */
45
110
  export declare const showSaveFilePicker: SaveFilePicker;
111
+ /**
112
+ * Same name and signature, answering file handles on every engine.
113
+ *
114
+ * The native picker is preferred wherever it can actually be shown, and its handles come back
115
+ * UNTOUCHED: they are structured cloneable, they can be re-granted after a reload, and wrapping them
116
+ * would take exactly that away. A cross origin frame is skipped rather than tried, since the platform
117
+ * refuses there and refusing costs part of the click the fallback still needs.
118
+ */
119
+ export declare const showOpenFilePicker: OpenFilePicker;
120
+ /**
121
+ * Same name and signature, answering a directory handle wherever reading one is possible.
122
+ *
123
+ * `mode: 'readwrite'` is refused where there is no native picker, and that refusal is the honest
124
+ * answer rather than a gap: an `<input>` hands over copies of bytes and there is no route from one
125
+ * back to the folder it came from. Refusing at the ask beats handing back a handle whose every write
126
+ * fails later.
127
+ */
128
+ export declare const showDirectoryPicker: DirectoryPicker;
46
129
  export {};
@@ -1,8 +1,14 @@
1
1
  //#region src/file-system.ts
2
+ var NO_SAVE_PICKER = "this browser has no file save picker";
3
+ var NO_FRAMED_PICKER = "a cross origin frame cannot show a file save picker";
4
+ var NO_DOCUMENT = "there is no document here to open a file picker from";
5
+ var NO_WRITE = "this file was opened from a picker that cannot write, so there is nothing to write to";
6
+ var NO_WRITE_FOLDER = "this browser cannot grant write access to a chosen folder";
7
+ var refuse = (message) => new DOMException(message, "NotAllowedError");
2
8
  /**
3
9
  * Whether this document is framed by another origin.
4
10
  *
5
- * Chromium exposes the picker either way and refuses it at call time, so a property probe says
11
+ * Chromium exposes the pickers either way and refuses them at call time, so a property probe says
6
12
  * nothing. A same origin ancestor answers `location.origin`; a cross origin one throws, and so does
7
13
  * an opaque origin, which is the case a sandboxed frame presents.
8
14
  */
@@ -17,11 +23,240 @@ var framedByAnotherOrigin = () => {
17
23
  return true;
18
24
  }
19
25
  };
26
+ /**
27
+ * The `accept` attribute for an `<input>`, from the picker's own `types`.
28
+ *
29
+ * Both halves of each entry go in. The attribute takes MIME types and extensions in one comma
30
+ * separated list, and an engine that does not recognise one of them ignores that one rather than the
31
+ * whole attribute, so listing both is strictly better than choosing. `*` patterns are dropped: they
32
+ * are what `excludeAcceptAllOption: false` already means, and an `accept` of `*` filters nothing
33
+ * while making the dialog claim it does.
34
+ */
35
+ var acceptFrom = (types) => {
36
+ const out = /* @__PURE__ */ new Set();
37
+ for (const type of types ?? []) for (const [mime, extensions] of Object.entries(type.accept ?? {})) {
38
+ if (mime && !mime.includes("*")) out.add(mime);
39
+ for (const extension of extensions) out.add(extension);
40
+ }
41
+ return [...out].join(",");
42
+ };
43
+ /**
44
+ * One pick through a detached `<input type="file">`, resolving the files or rejecting like a cancel.
45
+ *
46
+ * The `cancel` event is what makes this a promise that always settles, and it is why this fallback is
47
+ * worth having at all rather than being a hazard: measured present on all three engines above. An
48
+ * engine without it would leave a picker that was dismissed pending forever, which is worse than not
49
+ * offering one.
50
+ *
51
+ * NEEDS THE CALLER'S TRANSIENT ACTIVATION, exactly as the platform picker does. `click()` on a file
52
+ * input opens nothing without a gesture, so this has to be reached synchronously from the handler,
53
+ * and an `await` before it loses that. The same rule the native picker has, for the same reason.
54
+ *
55
+ * The input is attached and removed rather than left detached: a detached input's `click()` is
56
+ * ignored by some engines, and leaving it in the document would leave one element per pick behind.
57
+ */
58
+ var pickThroughInput = (setup) => {
59
+ const document = globalThis.document;
60
+ if (!document?.body) return Promise.reject(refuse(NO_DOCUMENT));
61
+ return new Promise((resolve, reject) => {
62
+ const input = document.createElement("input");
63
+ input.type = "file";
64
+ input.style.display = "none";
65
+ input.setAttribute("aria-hidden", "true");
66
+ setup(input);
67
+ const settle = (finish) => {
68
+ input.remove();
69
+ finish();
70
+ };
71
+ input.addEventListener("change", () => settle(() => resolve([...input.files ?? []])), { once: true });
72
+ input.addEventListener("cancel", () => settle(() => reject(new DOMException("the file picker was closed", "AbortError"))), { once: true });
73
+ document.body.append(input);
74
+ input.click();
75
+ });
76
+ };
77
+ var handles = /* @__PURE__ */ new WeakMap();
78
+ /**
79
+ * Entries in a fixed order, which the platform does not promise and the engines do not agree on.
80
+ *
81
+ * Measured above: the same tree comes out of `<input webkitdirectory>` in one order on Chromium and
82
+ * the opposite on Firefox and WebKit. Sorted by code unit rather than `localeCompare`, because a
83
+ * locale sensitive sort is one more thing that differs between two machines running the same code.
84
+ */
85
+ var ordered = (children) => [...children].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
86
+ var fileHandleFor = (node) => {
87
+ return {
88
+ kind: "file",
89
+ name: node.name,
90
+ /**
91
+ * The same `File` every time, where the platform hands back a fresh one.
92
+ *
93
+ * A `File` from an input is a snapshot taken when it was picked. It cannot be re-read from disk,
94
+ * so `lastModified` never moves and a staleness check across it can never fire. What it does do
95
+ * is throw on READ once the file underneath has changed, so a pass over the bytes still refuses
96
+ * to produce a mixture of two versions; it just reports it as a failed read rather than a
97
+ * changed file.
98
+ */
99
+ getFile: async () => node.file,
100
+ createWritable: async () => {
101
+ throw refuse(NO_WRITE);
102
+ },
103
+ isSameEntry: async (other) => other === handles.get(node)
104
+ };
105
+ };
106
+ var directoryHandleFor = (node) => {
107
+ const child = (name) => node.children.get(name);
108
+ const entries = async function* () {
109
+ for (const [name, entry] of ordered(node.children)) yield [name, handleFor(entry)];
110
+ };
111
+ return {
112
+ kind: "directory",
113
+ name: node.name,
114
+ entries,
115
+ keys: async function* () {
116
+ for (const [name] of ordered(node.children)) yield name;
117
+ },
118
+ values: async function* () {
119
+ for (const [, entry] of ordered(node.children)) yield handleFor(entry);
120
+ },
121
+ [Symbol.asyncIterator]: entries,
122
+ getFileHandle: async (name, options) => {
123
+ if (options?.create) throw refuse(NO_WRITE);
124
+ const found = child(name);
125
+ if (!found) throw new DOMException(`there is no ${name} here`, "NotFoundError");
126
+ if (found.kind !== "file") throw new DOMException(`${name} is a directory`, "TypeMismatchError");
127
+ return handleFor(found);
128
+ },
129
+ getDirectoryHandle: async (name, options) => {
130
+ if (options?.create) throw refuse(NO_WRITE);
131
+ const found = child(name);
132
+ if (!found) throw new DOMException(`there is no ${name} here`, "NotFoundError");
133
+ if (found.kind !== "directory") throw new DOMException(`${name} is a file`, "TypeMismatchError");
134
+ return handleFor(found);
135
+ },
136
+ removeEntry: async () => {
137
+ throw refuse(NO_WRITE);
138
+ },
139
+ /** The path from here down to a handle in this tree, or null where it is not in it. */
140
+ resolve: async (descendant) => {
141
+ const search = (from, path) => {
142
+ for (const [name, entry] of ordered(from.children)) {
143
+ if (handles.get(entry) === descendant) return [...path, name];
144
+ if (entry.kind === "directory") {
145
+ const deeper = search(entry, [...path, name]);
146
+ if (deeper) return deeper;
147
+ }
148
+ }
149
+ return null;
150
+ };
151
+ return handles.get(node) === descendant ? [] : search(node, []);
152
+ },
153
+ isSameEntry: async (other) => other === handles.get(node)
154
+ };
155
+ };
156
+ var handleFor = (node) => {
157
+ const made = handles.get(node);
158
+ if (made) return made;
159
+ const fresh = node.kind === "file" ? fileHandleFor(node) : directoryHandleFor(node);
160
+ handles.set(node, fresh);
161
+ return fresh;
162
+ };
163
+ /**
164
+ * The picked folder, rebuilt from the flat list an input hands over.
165
+ *
166
+ * `webkitRelativePath` is the whole tree already flattened: `Pack/Subs/E01.ass` for a folder called
167
+ * `Pack`. The first segment is the folder's own name, so it becomes the root's name and is dropped
168
+ * from every path under it, which is what makes this the same shape `showDirectoryPicker` returns.
169
+ *
170
+ * AN EMPTY FOLDER LOSES ITS NAME, and nothing can be done about that. The name is only ever learned
171
+ * from a file's path, so a folder with nothing in it comes back named `''`. It is a real pick rather
172
+ * than a cancel, since `change` fired, and reporting it as an empty directory is the closest true
173
+ * answer available.
174
+ *
175
+ * A name used twice, once as a file and once as a folder, resolves to the folder, because a path
176
+ * continuing through it proves it is one. Two files with the same path keep the last, which is what
177
+ * a map does and what re-picking the same tree would do anyway.
178
+ */
179
+ var treeFrom = (files) => {
180
+ const root = {
181
+ kind: "directory",
182
+ name: "",
183
+ children: /* @__PURE__ */ new Map()
184
+ };
185
+ for (const file of files) {
186
+ const segments = (file.webkitRelativePath || file.name).split("/").filter(Boolean);
187
+ if (!segments.length) continue;
188
+ const rooted = Boolean(file.webkitRelativePath) && segments.length > 1;
189
+ if (rooted && !root.name) root.name = segments[0];
190
+ const path = rooted ? segments.slice(1) : segments;
191
+ let level = root.children;
192
+ for (const name of path.slice(0, -1)) {
193
+ const existing = level.get(name);
194
+ const directory = existing?.kind === "directory" ? existing : {
195
+ kind: "directory",
196
+ name,
197
+ children: /* @__PURE__ */ new Map()
198
+ };
199
+ if (existing !== directory) level.set(name, directory);
200
+ level = directory.children;
201
+ }
202
+ const leaf = path[path.length - 1];
203
+ level.set(leaf, {
204
+ kind: "file",
205
+ name: leaf,
206
+ file
207
+ });
208
+ }
209
+ return root;
210
+ };
211
+ /**
212
+ * Same name and signature, refusing before the gesture is spent and never faking a save.
213
+ *
214
+ * The one picker with no fallback. Writing to a location somebody chose needs the platform's picker,
215
+ * and what an app does instead is a product decision rather than a shim: see the header.
216
+ */
20
217
  var showSaveFilePicker = async (options) => {
21
218
  const picker = globalThis.showSaveFilePicker;
22
- if (!picker) throw new DOMException("this browser has no file save picker", "NotAllowedError");
23
- if (framedByAnotherOrigin()) throw new DOMException("a cross origin frame cannot show a file save picker", "NotAllowedError");
219
+ if (!picker) throw refuse(NO_SAVE_PICKER);
220
+ if (framedByAnotherOrigin()) throw refuse(NO_FRAMED_PICKER);
24
221
  return picker(options);
25
222
  };
223
+ /**
224
+ * Same name and signature, answering file handles on every engine.
225
+ *
226
+ * The native picker is preferred wherever it can actually be shown, and its handles come back
227
+ * UNTOUCHED: they are structured cloneable, they can be re-granted after a reload, and wrapping them
228
+ * would take exactly that away. A cross origin frame is skipped rather than tried, since the platform
229
+ * refuses there and refusing costs part of the click the fallback still needs.
230
+ */
231
+ var showOpenFilePicker = async (options = {}) => {
232
+ const picker = globalThis.showOpenFilePicker;
233
+ if (picker && !framedByAnotherOrigin()) return picker(options);
234
+ const accept = acceptFrom(options.types);
235
+ return (await pickThroughInput((input) => {
236
+ input.multiple = options.multiple === true;
237
+ if (accept) input.accept = accept;
238
+ })).map((file) => handleFor({
239
+ kind: "file",
240
+ name: file.name,
241
+ file
242
+ }));
243
+ };
244
+ /**
245
+ * Same name and signature, answering a directory handle wherever reading one is possible.
246
+ *
247
+ * `mode: 'readwrite'` is refused where there is no native picker, and that refusal is the honest
248
+ * answer rather than a gap: an `<input>` hands over copies of bytes and there is no route from one
249
+ * back to the folder it came from. Refusing at the ask beats handing back a handle whose every write
250
+ * fails later.
251
+ */
252
+ var showDirectoryPicker = async (options = {}) => {
253
+ const picker = globalThis.showDirectoryPicker;
254
+ if (picker && !framedByAnotherOrigin()) return picker(options);
255
+ if (options.mode === "readwrite") throw refuse(NO_WRITE_FOLDER);
256
+ return handleFor(treeFrom(await pickThroughInput((input) => {
257
+ input.webkitdirectory = true;
258
+ input.multiple = true;
259
+ })));
260
+ };
26
261
  //#endregion
27
- export { showSaveFilePicker };
262
+ export { showDirectoryPicker, showOpenFilePicker, showSaveFilePicker };
package/build/index.cjs CHANGED
@@ -3,5 +3,7 @@ const require_storage = require("./storage.cjs");
3
3
  const require_permissions = require("./permissions.cjs");
4
4
  const require_file_system = require("./file-system.cjs");
5
5
  exports.permissions = require_permissions.permissions;
6
+ exports.showDirectoryPicker = require_file_system.showDirectoryPicker;
7
+ exports.showOpenFilePicker = require_file_system.showOpenFilePicker;
6
8
  exports.showSaveFilePicker = require_file_system.showSaveFilePicker;
7
9
  exports.storage = require_storage.storage;
package/build/index.d.ts CHANGED
@@ -34,6 +34,6 @@
34
34
  */
35
35
  export { storage } from './storage';
36
36
  export { permissions } from './permissions';
37
- export { showSaveFilePicker } from './file-system';
37
+ export { showDirectoryPicker, showOpenFilePicker, showSaveFilePicker } from './file-system';
38
38
  export type { StorageEstimate } from './storage';
39
39
  export type { PermissionStatus } from './permissions';
package/build/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  import { storage } from "./storage.js";
2
2
  import { permissions } from "./permissions.js";
3
- import { showSaveFilePicker } from "./file-system.js";
4
- export { permissions, showSaveFilePicker, storage };
3
+ import { showDirectoryPicker, showOpenFilePicker, showSaveFilePicker } from "./file-system.js";
4
+ export { permissions, showDirectoryPicker, showOpenFilePicker, showSaveFilePicker, storage };
package/build/storage.cjs CHANGED
@@ -91,15 +91,45 @@ var reconcile = (estimate, walked) => {
91
91
  * slightly early. Cache is the thing that can be fetched again; a caller that never reclaims because
92
92
  * it never sees pressure is the failure this replaces.
93
93
  *
94
- * In memory, and per page. Persisting it would mean owning storage to describe storage, and a reload
95
- * re-anchors on a figure the platform stated at that moment, which is the same guarantee.
94
+ * In memory, and per REALM rather than per page: a worker holds its own. Persisting it would mean
95
+ * owning storage to describe storage, and a reload re-anchors on a figure the platform stated at
96
+ * that moment, which is the same guarantee. What a realm cannot be allowed to do is hold a ceiling
97
+ * that has since moved, and the release below is how it lets go without any realm telling another.
96
98
  */
97
99
  var narrowest;
98
- var ceiling = (reported, usage) => {
100
+ /**
101
+ * Whether the origin was persistent when the ceiling above was latched.
102
+ *
103
+ * THE LATCH HAS TO BE ABLE TO LET GO, AND IN A REALM THAT NEVER ASKED. A grant moves the ceiling by
104
+ * orders of magnitude (measured 2026-09-01 on Firefox: 12 GB to 3.97 TB), so the figure learned
105
+ * before it is worse than useless afterwards. `persist()` is a MAIN THREAD call, because a worker's
106
+ * StorageManager has none, so a worker that latched 12 GB would hold it for its whole life while the
107
+ * page moved on. Anything deciding what to delete from that figure would go on deleting against a
108
+ * ceiling that no longer exists.
109
+ *
110
+ * So the release is keyed on the STATE rather than on the call: every `estimate()` reads
111
+ * `persisted()` alongside the quota, and a change since the latch drops it. That works in every
112
+ * realm, including the ones that could not have made the call, and it needs no shared state between
113
+ * them. `undefined` means the engine would not say, and an engine that will not say has not reported
114
+ * a change, so the latch stands.
115
+ */
116
+ var latchedUnder;
117
+ var ceiling = (reported, usage, persistent) => {
99
118
  if (reported === void 0) return void 0;
119
+ if (persistent !== latchedUnder) narrowest = void 0;
120
+ latchedUnder = persistent;
100
121
  narrowest = narrowest === void 0 ? reported : Math.min(narrowest, reported);
101
122
  return Math.max(narrowest, usage ?? 0);
102
123
  };
124
+ /**
125
+ * Whether this origin is persistent, or undefined where the engine has no way to be asked.
126
+ *
127
+ * A REJECTION IS NOT A CHANGE OF STATE, so it answers whatever the ceiling was latched under and the
128
+ * latch is left alone. Treating a failed read as a third value would make an engine that fails this
129
+ * call intermittently drop the ceiling every other estimate, which is the pick quietly turning
130
+ * itself off on exactly the engines least able to spare it.
131
+ */
132
+ var persistence = async (native) => native.persisted ? native.persisted().then((value) => value === true).catch(() => latchedUnder) : void 0;
103
133
  var storage = {
104
134
  /**
105
135
  * Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
@@ -112,11 +142,12 @@ var storage = {
112
142
  const native = globalThis.navigator?.storage;
113
143
  if (!native?.estimate) return {};
114
144
  const estimate = await native.estimate();
115
- const usage = reconcile(estimate, native.getDirectory ? await native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null);
145
+ const [walked, persistent] = await Promise.all([native.getDirectory ? native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null, persistence(native)]);
146
+ const usage = reconcile(estimate, walked);
116
147
  return {
117
148
  ...estimate,
118
149
  usage,
119
- quota: ceiling(estimate.quota, usage)
150
+ quota: ceiling(estimate.quota, usage, persistent)
120
151
  };
121
152
  },
122
153
  /**
@@ -131,12 +162,16 @@ var storage = {
131
162
  * left to do. So this resolves `persisted()`, falling back to the call's own answer only where the
132
163
  * platform will not state the state.
133
164
  *
134
- * SECOND, the ceiling. A granted persist can move the quota by orders of magnitude: measured
135
- * 2026-09-01 on Firefox, granting the "Store data in persistent storage" doorhanger moved the
136
- * reported quota from 12 GB to 3.97 TB on an 8.03 TB device, about 330 times. `estimate()` latches
137
- * the narrowest quota it has seen, which is right while nothing changes the ceiling and wrong the
138
- * moment something does, so a successful grant forgets it. The next `estimate()` re-anchors on
139
- * whatever the platform now says.
165
+ * SECOND, the ceiling, which this call does NOT release and deliberately so. A granted persist can
166
+ * move the quota by orders of magnitude: measured 2026-09-01 on Firefox, granting the "Store data
167
+ * in persistent storage" doorhanger moved the reported quota from 12 GB to 3.97 TB on an 8.03 TB
168
+ * device, about 330 times. `estimate()` latches the narrowest quota it has seen, which is right
169
+ * while nothing changes the ceiling and wrong the moment something does.
170
+ *
171
+ * Releasing it HERE would only release it in the realm that made the call, and this call can only
172
+ * be made from the main thread. So the release lives in `estimate()`, keyed on the persistence
173
+ * state rather than on the call, and every realm picks the change up on its next read. See
174
+ * `latchedUnder`.
140
175
  *
141
176
  * Chromium, measured 2026-08-30 on Chrome 151, refuses this on every attempt with no prompt shown
142
177
  * at any point, and the quota stays flat. That is not a failure to handle: it is the engine
@@ -146,9 +181,7 @@ var storage = {
146
181
  const native = globalThis.navigator?.storage;
147
182
  if (!native?.persist) return false;
148
183
  const answered = await native.persist().catch(() => false);
149
- const persisted = await native.persisted?.().catch(() => null) ?? null ?? answered;
150
- if (persisted) narrowest = void 0;
151
- return persisted;
184
+ return await persistence(native) ?? null ?? answered;
152
185
  },
153
186
  persisted: () => globalThis.navigator?.storage?.persisted?.() ?? Promise.resolve(false),
154
187
  getDirectory: () => {
@@ -93,12 +93,16 @@ export declare const storage: {
93
93
  * left to do. So this resolves `persisted()`, falling back to the call's own answer only where the
94
94
  * platform will not state the state.
95
95
  *
96
- * SECOND, the ceiling. A granted persist can move the quota by orders of magnitude: measured
97
- * 2026-09-01 on Firefox, granting the "Store data in persistent storage" doorhanger moved the
98
- * reported quota from 12 GB to 3.97 TB on an 8.03 TB device, about 330 times. `estimate()` latches
99
- * the narrowest quota it has seen, which is right while nothing changes the ceiling and wrong the
100
- * moment something does, so a successful grant forgets it. The next `estimate()` re-anchors on
101
- * whatever the platform now says.
96
+ * SECOND, the ceiling, which this call does NOT release and deliberately so. A granted persist can
97
+ * move the quota by orders of magnitude: measured 2026-09-01 on Firefox, granting the "Store data
98
+ * in persistent storage" doorhanger moved the reported quota from 12 GB to 3.97 TB on an 8.03 TB
99
+ * device, about 330 times. `estimate()` latches the narrowest quota it has seen, which is right
100
+ * while nothing changes the ceiling and wrong the moment something does.
101
+ *
102
+ * Releasing it HERE would only release it in the realm that made the call, and this call can only
103
+ * be made from the main thread. So the release lives in `estimate()`, keyed on the persistence
104
+ * state rather than on the call, and every realm picks the change up on its next read. See
105
+ * `latchedUnder`.
102
106
  *
103
107
  * Chromium, measured 2026-08-30 on Chrome 151, refuses this on every attempt with no prompt shown
104
108
  * at any point, and the quota stays flat. That is not a failure to handle: it is the engine
package/build/storage.js CHANGED
@@ -90,15 +90,45 @@ var reconcile = (estimate, walked) => {
90
90
  * slightly early. Cache is the thing that can be fetched again; a caller that never reclaims because
91
91
  * it never sees pressure is the failure this replaces.
92
92
  *
93
- * In memory, and per page. Persisting it would mean owning storage to describe storage, and a reload
94
- * re-anchors on a figure the platform stated at that moment, which is the same guarantee.
93
+ * In memory, and per REALM rather than per page: a worker holds its own. Persisting it would mean
94
+ * owning storage to describe storage, and a reload re-anchors on a figure the platform stated at
95
+ * that moment, which is the same guarantee. What a realm cannot be allowed to do is hold a ceiling
96
+ * that has since moved, and the release below is how it lets go without any realm telling another.
95
97
  */
96
98
  var narrowest;
97
- var ceiling = (reported, usage) => {
99
+ /**
100
+ * Whether the origin was persistent when the ceiling above was latched.
101
+ *
102
+ * THE LATCH HAS TO BE ABLE TO LET GO, AND IN A REALM THAT NEVER ASKED. A grant moves the ceiling by
103
+ * orders of magnitude (measured 2026-09-01 on Firefox: 12 GB to 3.97 TB), so the figure learned
104
+ * before it is worse than useless afterwards. `persist()` is a MAIN THREAD call, because a worker's
105
+ * StorageManager has none, so a worker that latched 12 GB would hold it for its whole life while the
106
+ * page moved on. Anything deciding what to delete from that figure would go on deleting against a
107
+ * ceiling that no longer exists.
108
+ *
109
+ * So the release is keyed on the STATE rather than on the call: every `estimate()` reads
110
+ * `persisted()` alongside the quota, and a change since the latch drops it. That works in every
111
+ * realm, including the ones that could not have made the call, and it needs no shared state between
112
+ * them. `undefined` means the engine would not say, and an engine that will not say has not reported
113
+ * a change, so the latch stands.
114
+ */
115
+ var latchedUnder;
116
+ var ceiling = (reported, usage, persistent) => {
98
117
  if (reported === void 0) return void 0;
118
+ if (persistent !== latchedUnder) narrowest = void 0;
119
+ latchedUnder = persistent;
99
120
  narrowest = narrowest === void 0 ? reported : Math.min(narrowest, reported);
100
121
  return Math.max(narrowest, usage ?? 0);
101
122
  };
123
+ /**
124
+ * Whether this origin is persistent, or undefined where the engine has no way to be asked.
125
+ *
126
+ * A REJECTION IS NOT A CHANGE OF STATE, so it answers whatever the ceiling was latched under and the
127
+ * latch is left alone. Treating a failed read as a third value would make an engine that fails this
128
+ * call intermittently drop the ceiling every other estimate, which is the pick quietly turning
129
+ * itself off on exactly the engines least able to spare it.
130
+ */
131
+ var persistence = async (native) => native.persisted ? native.persisted().then((value) => value === true).catch(() => latchedUnder) : void 0;
102
132
  var storage = {
103
133
  /**
104
134
  * Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
@@ -111,11 +141,12 @@ var storage = {
111
141
  const native = globalThis.navigator?.storage;
112
142
  if (!native?.estimate) return {};
113
143
  const estimate = await native.estimate();
114
- const usage = reconcile(estimate, native.getDirectory ? await native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null);
144
+ const [walked, persistent] = await Promise.all([native.getDirectory ? native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null, persistence(native)]);
145
+ const usage = reconcile(estimate, walked);
115
146
  return {
116
147
  ...estimate,
117
148
  usage,
118
- quota: ceiling(estimate.quota, usage)
149
+ quota: ceiling(estimate.quota, usage, persistent)
119
150
  };
120
151
  },
121
152
  /**
@@ -130,12 +161,16 @@ var storage = {
130
161
  * left to do. So this resolves `persisted()`, falling back to the call's own answer only where the
131
162
  * platform will not state the state.
132
163
  *
133
- * SECOND, the ceiling. A granted persist can move the quota by orders of magnitude: measured
134
- * 2026-09-01 on Firefox, granting the "Store data in persistent storage" doorhanger moved the
135
- * reported quota from 12 GB to 3.97 TB on an 8.03 TB device, about 330 times. `estimate()` latches
136
- * the narrowest quota it has seen, which is right while nothing changes the ceiling and wrong the
137
- * moment something does, so a successful grant forgets it. The next `estimate()` re-anchors on
138
- * whatever the platform now says.
164
+ * SECOND, the ceiling, which this call does NOT release and deliberately so. A granted persist can
165
+ * move the quota by orders of magnitude: measured 2026-09-01 on Firefox, granting the "Store data
166
+ * in persistent storage" doorhanger moved the reported quota from 12 GB to 3.97 TB on an 8.03 TB
167
+ * device, about 330 times. `estimate()` latches the narrowest quota it has seen, which is right
168
+ * while nothing changes the ceiling and wrong the moment something does.
169
+ *
170
+ * Releasing it HERE would only release it in the realm that made the call, and this call can only
171
+ * be made from the main thread. So the release lives in `estimate()`, keyed on the persistence
172
+ * state rather than on the call, and every realm picks the change up on its next read. See
173
+ * `latchedUnder`.
139
174
  *
140
175
  * Chromium, measured 2026-08-30 on Chrome 151, refuses this on every attempt with no prompt shown
141
176
  * at any point, and the quota stays flat. That is not a failure to handle: it is the engine
@@ -145,9 +180,7 @@ var storage = {
145
180
  const native = globalThis.navigator?.storage;
146
181
  if (!native?.persist) return false;
147
182
  const answered = await native.persist().catch(() => false);
148
- const persisted = await native.persisted?.().catch(() => null) ?? null ?? answered;
149
- if (persisted) narrowest = void 0;
150
- return persisted;
183
+ return await persistence(native) ?? null ?? answered;
151
184
  },
152
185
  persisted: () => globalThis.navigator?.storage?.persisted?.() ?? Promise.resolve(false),
153
186
  getDirectory: () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/ponyfill",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "main": "build/index.cjs",
6
6
  "module": "build/index.js",